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" }