diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5824a2cc4..97a6d711d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -209,9 +209,10 @@ + android:value="38" /> + diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFragment.kt index c87e06d6b..208816d9f 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFragment.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFragment.kt @@ -1,5 +1,7 @@ package net.opendasharchive.openarchive.services.snowbird +import android.content.Intent +import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri @@ -17,21 +19,30 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -42,11 +53,13 @@ import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.fragment.findNavController +import com.google.zxing.BarcodeFormat import com.google.zxing.BinaryBitmap import com.google.zxing.DecodeHintType import com.google.zxing.MultiFormatReader @@ -57,15 +70,17 @@ import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.core.presentation.theme.DefaultScaffoldPreview import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme +import net.opendasharchive.openarchive.core.presentation.theme.SaveTextStyles import net.opendasharchive.openarchive.core.presentation.theme.ThemeColors import net.opendasharchive.openarchive.core.presentation.theme.ThemeDimensions import net.opendasharchive.openarchive.db.SnowbirdGroup import net.opendasharchive.openarchive.extensions.getQueryParameter -import net.opendasharchive.openarchive.features.core.BaseFragment import net.opendasharchive.openarchive.features.core.UiText import net.opendasharchive.openarchive.features.core.dialog.DialogType import net.opendasharchive.openarchive.features.core.dialog.showDialog import net.opendasharchive.openarchive.features.main.QRScannerActivity +import net.opendasharchive.openarchive.services.snowbird.service.ServiceStatus +import net.opendasharchive.openarchive.services.snowbird.service.SnowbirdService class SnowbirdFragment : BaseSnowbirdFragment() { @@ -126,7 +141,14 @@ class SnowbirdFragment : BaseSnowbirdFragment() { SnowbirdScreen( onJoinGroup = onJoinGroup, onCreateGroup = onCreateGroup, - onMyGroups = onMyGroups + onMyGroups = onMyGroups, + onServerToggle = { enabled -> + if (enabled) { + requireContext().startForegroundService(Intent(requireContext(), SnowbirdService::class.java)) + } else { + requireContext().stopService(Intent(requireContext(), SnowbirdService::class.java)) + } + } ) } } @@ -207,7 +229,7 @@ class SnowbirdFragment : BaseSnowbirdFragment() { val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) val reader = MultiFormatReader() - val hints = mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(com.google.zxing.BarcodeFormat.QR_CODE)) + val hints = mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)) reader.setHints(hints) return try { @@ -271,17 +293,24 @@ class SnowbirdFragment : BaseSnowbirdFragment() { fun SnowbirdScreen( onJoinGroup: () -> Unit = {}, onCreateGroup: () -> Unit = {}, - onMyGroups: () -> Unit = {} + onMyGroups: () -> Unit = {}, + onServerToggle: (Boolean) -> Unit = {} ) { + // Observe server status + val serverStatus by SnowbirdService.serviceStatus.collectAsState() + + // Get navigation bar insets for edge-to-edge support + val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues() + // Use a scrollable Column to mimic ScrollView + LinearLayout Column( modifier = Modifier .fillMaxSize() - .padding(top = 32.dp, bottom = 16.dp) - .padding(horizontal = 24.dp), + .padding(top = 32.dp) + .padding(horizontal = 24.dp) + .padding(bottom = navigationBarPadding.calculateBottomPadding() + 16.dp), ) { - // Header texts SpaceAuthHeader( description = "Preserve your media on the decentralized web (DWeb) Storage.", @@ -306,14 +335,101 @@ fun SnowbirdScreen( onClick = onCreateGroup ) - - DwebOptionItem( title = "My groups", subtitle = "View and manage your groups", onClick = onMyGroups ) + Spacer(modifier = Modifier.weight(1f)) + + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + + // Server Control Section at the bottom using custom preference style + DwebServerPreference( + serverStatus = serverStatus, + onToggle = onServerToggle + ) + + } +} + +@Composable +fun DwebServerPreference( + serverStatus: ServiceStatus, + onToggle: (Boolean) -> Unit +) { + val isServerEnabled = serverStatus !is ServiceStatus.Stopped + val isConnecting = serverStatus is ServiceStatus.Connecting + + // Summary text based on status + val summaryText = when (serverStatus) { + is ServiceStatus.Stopped -> "Enable to share and sync media" + is ServiceStatus.Connecting -> "Connecting..." + is ServiceStatus.Connected -> "Running on localhost:8080" + is ServiceStatus.Failed -> "Failed to start. Try again." + } + + // Custom preference-style UI + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = isServerEnabled, + enabled = !isConnecting, + role = Role.Switch, + onValueChange = onToggle + ) + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Title and Summary + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = "DWeb Server", + style = SaveTextStyles.bodyLarge, + color = if (!isConnecting) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + } + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = summaryText, + style = SaveTextStyles.bodySmallEmphasis, + color = if (!isConnecting) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + } + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + // Switch with custom colors + Switch( + checked = isServerEnabled, + onCheckedChange = null, // Handled by toggleable modifier + enabled = !isConnecting, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.surface, + checkedTrackColor = MaterialTheme.colorScheme.tertiary, + uncheckedThumbColor = MaterialTheme.colorScheme.outline, + uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) } } @@ -321,7 +437,6 @@ fun SnowbirdScreen( @Composable private fun SnowbirdScreenPreview() { DefaultScaffoldPreview { - SnowbirdScreen() } } @@ -432,9 +547,49 @@ fun SpaceAuthHeader( @Composable @Preview(showBackground = true) -@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun SpaceAuthHeaderPreview() { SaveAppTheme { SpaceAuthHeader() } +} + +@Preview +@Composable +private fun DwebServerPreferencePreview() { + SaveAppTheme { + Column( + modifier = Modifier.fillMaxSize() + ) { + Text("Stopped:", modifier = Modifier.padding(8.dp)) + DwebServerPreference( + serverStatus = ServiceStatus.Stopped, + onToggle = {} + ) + + HorizontalDivider() + + Text("Connecting:", modifier = Modifier.padding(8.dp)) + DwebServerPreference( + serverStatus = ServiceStatus.Connecting, + onToggle = {} + ) + + HorizontalDivider() + + Text("Connected:", modifier = Modifier.padding(8.dp)) + DwebServerPreference( + serverStatus = ServiceStatus.Connected, + onToggle = {} + ) + + HorizontalDivider() + + Text("Failed:", modifier = Modifier.padding(8.dp)) + DwebServerPreference( + serverStatus = ServiceStatus.Failed(Throwable("Failed to start")), + onToggle = {} + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdService.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdService.kt index afa3d7458..25f856615 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdService.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdService.kt @@ -11,7 +11,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -41,15 +43,24 @@ class SnowbirdService : Service() { var DEFAULT_SOCKET_PATH = "" private set + + // Expose service status globally so UI can observe it + private val _serviceStatus = MutableStateFlow(ServiceStatus.Stopped) + val serviceStatus: StateFlow = _serviceStatus.asStateFlow() + + // Helper to get current status synchronously + fun getCurrentStatus(): ServiceStatus = _serviceStatus.value + + // Internal setter for the service to update status + internal fun updateStatus(status: ServiceStatus) { + _serviceStatus.value = status + } } private var serverJob: Job? = null private var pollingJob: Job? = null private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val _serviceStatus = MutableStateFlow(ServiceStatus.Stopped) - val serviceStatus = _serviceStatus.asStateFlow() - override fun onCreate() { super.onCreate() @@ -94,7 +105,38 @@ class SnowbirdService : Service() { } override fun onDestroy() { - stopServer() + Timber.d("SnowbirdService onDestroy called - stopping server") + + // Cancel polling and server jobs + pollingJob?.cancel() + serverJob?.cancel() + + // Update status to stopping + updateStatus(ServiceStatus.Stopped) + + // Actually stop the Rust server + serviceScope.launch { + try { + updateNotification("Stopping server...") + Timber.d("Calling SnowbirdBridge.stopServer()") + + // Call the Rust stopServer function via JNI + withContext(Dispatchers.IO) { + val result = SnowbirdBridge.getInstance().stopServer() + Timber.d("Server stopped: $result") + } + + // Give it a moment to clean up + delay(500) + + updateStatus(ServiceStatus.Stopped) + Timber.d("Server shutdown complete") + } catch (e: Exception) { + Timber.e(e, "Error stopping server") + updateStatus(ServiceStatus.Failed(e)) + } + } + super.onDestroy() } @@ -187,21 +229,21 @@ class SnowbirdService : Service() { val attemptNumber = attempt.attempt when (attempt) { is RetryAttempt.Success -> { - _serviceStatus.value = ServiceStatus.Connected + updateStatus(ServiceStatus.Connected) updateNotification("Service Connected", withSound = true) Timber.d("Service is up after $attemptNumber attempt(s)") stopPolling() } is RetryAttempt.Retry -> { - _serviceStatus.value = ServiceStatus.Connecting + updateStatus(ServiceStatus.Connecting) updateNotification("Connecting... (Attempt $attemptNumber) One moment please.") Timber.d("Attempt $attemptNumber failed, retrying...") } is RetryAttempt.Failure -> { val errorMessage = attempt.error.message ?: "Unknown error" - _serviceStatus.value = ServiceStatus.Failed(attempt.error) + updateStatus(ServiceStatus.Failed(attempt.error)) updateNotification("Connection Failed: $errorMessage") Timber.e(attempt.error) stopPolling() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 76a3222ee..3620da26e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,7 @@ clean-insights = "2.8.0" coil = "3.3.0" compose = "1.9.5" compose-material-icons = "1.7.8" -compose-preference = "1.1.1" +compose-preference = "2.1.0" constraintlayout = "2.2.1" constraintlayout-compos = "1.1.1" coordinatorlayout = "1.3.0" @@ -155,7 +155,7 @@ androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" } # Compose - Third Party -compose-preferences = { group = "me.zhanghai.compose.preference", name = "library", version.ref = "compose-preference" } +compose-preferences = { group = "me.zhanghai.compose.preference", name = "preference", version.ref = "compose-preference" } # Detekt detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" }