diff --git a/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt b/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt index 7b14573..a4ee0af 100644 --- a/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt +++ b/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt @@ -11,8 +11,10 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.update.UpdateSource import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateTrack +import io.nekohasekai.sfa.update.checkFDroidUpdate import java.util.concurrent.TimeUnit class UpdateWorker(private val appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { @@ -59,8 +61,13 @@ class UpdateWorker(private val appContext: Context, params: WorkerParameters) : Log.d(TAG, "Checking for updates...") return try { - val track = UpdateTrack.fromString(Settings.updateTrack) - val updateInfo = GitHubUpdateChecker().use { it.checkUpdate(track) } + val updateInfo = when (UpdateSource.fromString(Settings.updateSource)) { + UpdateSource.FDROID -> checkFDroidUpdate(appContext) + UpdateSource.GITHUB -> { + val track = UpdateTrack.fromString(Settings.updateTrack) + GitHubUpdateChecker().use { it.checkUpdate(track) } + } + } if (updateInfo == null) { Log.d(TAG, "No update available") diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt index 2f46d17..d6e5f22 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt @@ -28,6 +28,7 @@ import io.nekohasekai.sfa.compose.screen.profile.EditProfileRoute import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScreen import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen +import io.nekohasekai.sfa.compose.screen.settings.FDroidMirrorScreen import io.nekohasekai.sfa.compose.screen.settings.PrivilegeSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen @@ -224,6 +225,16 @@ fun SFANavHost( AppSettingsScreen(navController = navController) } + composable( + route = "settings/fdroid_mirror", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + FDroidMirrorScreen(navController = navController) + } + composable( route = "settings/core", enterTransition = slideInFromRight, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt index 8aa6d21..99c82d2 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt @@ -7,6 +7,7 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build +import android.text.format.Formatter import android.util.Log import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.background @@ -27,6 +28,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.outlined.AdminPanelSettings import androidx.compose.material.icons.outlined.Autorenew +import androidx.compose.material.icons.outlined.DeleteForever +import androidx.compose.material.icons.outlined.DeleteSweep import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Language @@ -62,6 +65,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -71,6 +75,7 @@ import androidx.core.os.LocaleListCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.R @@ -78,6 +83,7 @@ import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.update.UpdateCheckException +import io.nekohasekai.sfa.update.UpdateSource import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateTrack import io.nekohasekai.sfa.utils.HookStatusClient @@ -88,6 +94,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.xmlpull.v1.XmlPullParser +import java.io.File import java.util.Locale import android.provider.Settings as AndroidSettings @@ -113,10 +120,12 @@ fun AppSettingsScreen(navController: NavController) { val hasUpdate by UpdateState.hasUpdate val updateInfo by UpdateState.updateInfo val isChecking by UpdateState.isChecking + var showSourceDialog by remember { mutableStateOf(false) } + var currentSource by remember { mutableStateOf(Settings.updateSource) } var showTrackDialog by remember { mutableStateOf(false) } var currentTrack by remember { mutableStateOf(Settings.updateTrack) } var checkUpdateEnabled by remember { mutableStateOf(Settings.checkUpdateEnabled) } - var showErrorDialog by remember { mutableStateOf(null) } + var showErrorDialog by remember { mutableStateOf(null) } var silentInstallEnabled by remember { mutableStateOf(Settings.silentInstallEnabled) } var silentInstallMethod by remember { mutableStateOf(Settings.silentInstallMethod) } @@ -144,8 +153,22 @@ fun AppSettingsScreen(navController: NavController) { mutableStateOf(if (appLocales.isEmpty) "" else appLocales.toLanguageTags()) } + var cacheSize by remember { mutableStateOf(0L) } + var cacheSizeText by remember { mutableStateOf("") } + + fun refreshCacheSize() { + scope.launch(Dispatchers.IO) { + val size = calculateDirSize(context.cacheDir) + withContext(Dispatchers.Main) { + cacheSize = size + cacheSizeText = Formatter.formatFileSize(context, size) + } + } + } + LaunchedEffect(Unit) { HookStatusClient.refresh() + refreshCacheSize() } // Re-check states when returning from background (e.g., after granting permission) @@ -183,6 +206,21 @@ fun AppSettingsScreen(navController: NavController) { } } + if (showSourceDialog) { + UpdateSourceDialog( + currentSource = currentSource, + onSourceSelected = { source -> + currentSource = source + UpdateState.clear() + scope.launch(Dispatchers.IO) { + Settings.updateSource = source + } + showSourceDialog = false + }, + onDismiss = { showSourceDialog = false }, + ) + } + if (showTrackDialog) { UpdateTrackDialog( currentTrack = currentTrack, @@ -198,11 +236,11 @@ fun AppSettingsScreen(navController: NavController) { ) } - showErrorDialog?.let { messageRes -> + showErrorDialog?.let { message -> AlertDialog( onDismissRequest = { showErrorDialog = null }, title = { Text(stringResource(R.string.check_update)) }, - text = { Text(stringResource(messageRes)) }, + text = { Text(message) }, confirmButton = { TextButton(onClick = { showErrorDialog = null }) { Text(stringResource(R.string.ok)) @@ -440,13 +478,80 @@ fun AppSettingsScreen(navController: NavController) { }, modifier = Modifier - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) .clickable { showLanguageDialog = true }, colors = ListItemDefaults.colors( containerColor = Color.Transparent, ), ) + + ListItem( + headlineContent = { + Text( + stringResource(R.string.cache_size), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + if (cacheSizeText.isNotEmpty()) { + Text(cacheSizeText, style = MaterialTheme.typography.bodyMedium) + } + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.DeleteSweep, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clip( + if (cacheSize > 0L) { + RoundedCornerShape(0.dp) + } else { + RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + }, + ), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + if (cacheSize > 0L) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.clear_cache), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.DeleteForever, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { + scope.launch(Dispatchers.IO) { + context.cacheDir?.listFiles()?.forEach { it.deleteRecursively() } + withContext(Dispatchers.Main) { + cacheSize = 0L + cacheSizeText = Formatter.formatFileSize(context, 0L) + } + } + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } } } @@ -555,14 +660,21 @@ fun AppSettingsScreen(navController: NavController) { ), ) { Column { + val isFDroid = UpdateSource.fromString(currentSource) == UpdateSource.FDROID val updateItemCount = run { var count = 0 - if (Vendor.supportsTrackSelection()) { + if (Vendor.updateSources.size > 1) { + count += 1 + } + if (Vendor.hasCustomUpdate) { + count += 1 + } + if (isFDroid) { count += 1 } count += 1 - if (Vendor.supportsSilentInstall()) { + if (Vendor.hasCustomUpdate) { count += 1 if (silentInstallEnabled) { count += 1 @@ -574,7 +686,7 @@ fun AppSettingsScreen(navController: NavController) { } } } - if (Vendor.supportsAutoUpdate()) { + if (Vendor.hasCustomUpdate) { count += 1 } count @@ -592,7 +704,39 @@ fun AppSettingsScreen(navController: NavController) { } } - if (Vendor.supportsTrackSelection()) { + if (Vendor.updateSources.size > 1) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.update_source), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + val sourceName = when (UpdateSource.fromString(currentSource)) { + UpdateSource.GITHUB -> stringResource(R.string.update_source_github) + UpdateSource.FDROID -> stringResource(R.string.update_source_fdroid) + } + Text(sourceName, style = MaterialTheme.typography.bodyMedium) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.NewReleases, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + updateItemModifier() + .clickable { showSourceDialog = true }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + if (Vendor.hasCustomUpdate) { ListItem( headlineContent = { Text( @@ -601,9 +745,13 @@ fun AppSettingsScreen(navController: NavController) { ) }, supportingContent = { - val trackName = when (UpdateTrack.fromString(currentTrack)) { - UpdateTrack.STABLE -> stringResource(R.string.update_track_stable) - UpdateTrack.BETA -> stringResource(R.string.update_track_beta) + val trackName = if (isFDroid) { + stringResource(R.string.update_track_stable) + } else { + when (UpdateTrack.fromString(currentTrack)) { + UpdateTrack.STABLE -> stringResource(R.string.update_track_stable) + UpdateTrack.BETA -> stringResource(R.string.update_track_beta) + } } Text(trackName, style = MaterialTheme.typography.bodyMedium) }, @@ -615,8 +763,63 @@ fun AppSettingsScreen(navController: NavController) { ) }, modifier = + updateItemModifier().let { + if (isFDroid) it.alpha(0.38f) else it.clickable { showTrackDialog = true } + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + if (isFDroid) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.fdroid_mirror), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + val mirrorUrl = Settings.fdroidMirrorUrl + val mirrorName = remember(mirrorUrl) { + val iter = Libbox.getFDroidMirrors() + var name: String? = null + while (iter.hasNext()) { + val m = iter.next() + if (m.url == mirrorUrl) { + name = m.name + break + } + } + if (name == null) { + val customMirrors = Settings.fdroidCustomMirrors + for (entry in customMirrors) { + val parts = entry.split("|", limit = 2) + if (parts.size == 2 && parts[1] == mirrorUrl) { + name = parts[0] + break + } + } + } + name ?: mirrorUrl + } + Text( + mirrorName, + style = MaterialTheme.typography.bodyMedium, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Speed, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = updateItemModifier() - .clickable { showTrackDialog = true }, + .clickable { navController.navigate("settings/fdroid_mirror") }, colors = ListItemDefaults.colors( containerColor = Color.Transparent, @@ -656,7 +859,7 @@ fun AppSettingsScreen(navController: NavController) { ), ) - if (Vendor.supportsSilentInstall()) { + if (Vendor.hasCustomUpdate) { ListItem( headlineContent = { Text( @@ -836,7 +1039,7 @@ fun AppSettingsScreen(navController: NavController) { } } - if (Vendor.supportsAutoUpdate()) { + if (Vendor.hasCustomUpdate) { ListItem( headlineContent = { Text( @@ -940,15 +1143,17 @@ fun AppSettingsScreen(navController: NavController) { val result = Vendor.checkUpdateAsync() UpdateState.setUpdate(result) if (result == null) { - showErrorDialog = R.string.no_updates_available + showErrorDialog = context.getString(R.string.no_updates_available) } else { showUpdateAvailableDialog = true } } catch (_: UpdateCheckException.TrackNotSupported) { UpdateState.setUpdate(null) - showErrorDialog = R.string.update_track_not_supported - } catch (_: Exception) { + showErrorDialog = context.getString(R.string.update_track_not_supported) + } catch (e: Exception) { + Log.e("AppSettingsScreen", "checkUpdateAsync failed", e) UpdateState.setUpdate(null) + showErrorDialog = e.message } } UpdateState.isChecking.value = false @@ -998,6 +1203,53 @@ fun AppSettingsScreen(navController: NavController) { } } +@Composable +private fun UpdateSourceDialog( + currentSource: String, + onSourceSelected: (String) -> Unit, + onDismiss: () -> Unit, +) { + val sources = listOf( + "github" to stringResource(R.string.update_source_github), + "fdroid" to stringResource(R.string.update_source_fdroid), + ) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.update_source)) }, + text = { + Column { + sources.forEach { (value, label) -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onSourceSelected(value) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = currentSource == value, + onClick = { onSourceSelected(value) }, + ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} + @Composable private fun UpdateTrackDialog( currentTrack: String, @@ -1108,6 +1360,15 @@ private fun LanguageDialog( ) } +private fun calculateDirSize(dir: File?): Long { + if (dir == null || !dir.exists()) return 0 + var size = 0L + dir.listFiles()?.forEach { file -> + size += if (file.isDirectory) calculateDirSize(file) else file.length() + } + return size +} + private fun getSupportedLocales(context: Context): List { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val localeConfig = LocaleConfig(context) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/FDroidMirrorScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/FDroidMirrorScreen.kt new file mode 100644 index 0000000..1bce659 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/FDroidMirrorScreen.kt @@ -0,0 +1,456 @@ +package io.nekohasekai.sfa.compose.screen.settings + +import android.webkit.URLUtil +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Speed +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.database.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private data class MirrorEntry( + val url: String, + val name: String, + val country: String, + val isCustom: Boolean = false, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FDroidMirrorScreen(navController: NavController) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.fdroid_mirror)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + ) + } + + val scope = rememberCoroutineScope() + var selectedMirrorUrl by remember { mutableStateOf(Settings.fdroidMirrorUrl) } + var isTesting by remember { mutableStateOf(false) } + val latencyResults = remember { mutableStateMapOf() } + val latencyErrors = remember { mutableStateMapOf() } + var showAddForm by remember { mutableStateOf(false) } + var newMirrorName by remember { mutableStateOf("") } + var newMirrorUrl by remember { mutableStateOf("") } + var urlError by remember { mutableStateOf(null) } + val invalidUrlMessage = stringResource(R.string.fdroid_mirror_invalid_url) + var customMirrors by remember { mutableStateOf(Settings.fdroidCustomMirrors) } + + val builtinMirrors = remember { + val mirrors = mutableListOf() + val iter = Libbox.getFDroidMirrors() + while (iter.hasNext()) { + val m = iter.next() + mirrors.add(MirrorEntry(url = m.url, name = m.name, country = m.country)) + } + mirrors + } + + val parsedCustomMirrors = remember(customMirrors) { + customMirrors.map { entry -> + val parts = entry.split("|", limit = 2) + if (parts.size == 2) { + MirrorEntry(url = parts[1], name = parts[0], country = "", isCustom = true) + } else { + MirrorEntry(url = entry, name = entry, country = "", isCustom = true) + } + } + } + + val allMirrors = builtinMirrors + parsedCustomMirrors + + fun selectMirror(url: String) { + selectedMirrorUrl = url + Settings.fdroidMirrorUrl = url + } + + fun testAllMirrors() { + isTesting = true + latencyResults.clear() + latencyErrors.clear() + scope.launch { + allMirrors.map { mirror -> + async(Dispatchers.IO) { + val r = Libbox.pingFDroidMirror(mirror.url) + withContext(Dispatchers.Main) { + if (r.latencyMs < 0) { + latencyErrors[r.url] = true + } else { + latencyResults[r.url] = r.latencyMs + } + } + } + }.awaitAll() + val fastest = latencyResults.minByOrNull { it.value } + if (fastest != null) { + selectMirror(fastest.key) + } + isTesting = false + } + } + + val grouped = remember(builtinMirrors) { + builtinMirrors.groupBy { it.country } + } + val countryOrder = remember(grouped) { grouped.keys.toList() } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + FilledTonalButton( + onClick = { testAllMirrors() }, + enabled = !isTesting, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + if (isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.fdroid_mirror_testing)) + } else { + Icon( + imageVector = Icons.Outlined.Speed, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.fdroid_mirror_test_all)) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + countryOrder.forEach { country -> + val mirrors = grouped[country] ?: return@forEach + + Text( + text = country, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + mirrors.forEachIndexed { index, mirror -> + val shape = when { + mirrors.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == mirrors.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + mirror.name, + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + RadioButton( + selected = selectedMirrorUrl == mirror.url, + onClick = { selectMirror(mirror.url) }, + ) + }, + trailingContent = { + LatencyBadge( + url = mirror.url, + latencyResults = latencyResults, + latencyErrors = latencyErrors, + ) + }, + modifier = Modifier + .clip(shape) + .clickable { selectMirror(mirror.url) }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + + Text( + text = stringResource(R.string.fdroid_mirror_custom), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + parsedCustomMirrors.forEachIndexed { index, mirror -> + val isLast = index == parsedCustomMirrors.lastIndex && !showAddForm + val shape = when { + index == 0 && isLast -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + isLast -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + mirror.name, + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + mirror.url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + leadingContent = { + RadioButton( + selected = selectedMirrorUrl == mirror.url, + onClick = { selectMirror(mirror.url) }, + ) + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + LatencyBadge( + url = mirror.url, + latencyResults = latencyResults, + latencyErrors = latencyErrors, + ) + IconButton(onClick = { + val encoded = "${mirror.name}|${mirror.url}" + val newSet = customMirrors.toMutableSet() + newSet.remove(encoded) + customMirrors = newSet + Settings.fdroidCustomMirrors = newSet + if (selectedMirrorUrl == mirror.url) { + selectMirror("https://f-droid.org/repo") + } + }) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.fdroid_mirror_delete), + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + modifier = Modifier + .clip(shape) + .clickable { selectMirror(mirror.url) }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + if (showAddForm) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = newMirrorName, + onValueChange = { newMirrorName = it }, + label = { Text(stringResource(R.string.fdroid_mirror_name_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = newMirrorUrl, + onValueChange = { + newMirrorUrl = it + urlError = null + }, + label = { Text(stringResource(R.string.fdroid_mirror_url_hint)) }, + singleLine = true, + isError = urlError != null, + supportingText = urlError?.let { { Text(it) } }, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Button(onClick = { + val url = newMirrorUrl.trim().trimEnd('/') + if (!URLUtil.isHttpsUrl(url)) { + urlError = invalidUrlMessage + return@Button + } + val name = newMirrorName.trim().ifEmpty { url } + val encoded = "$name|$url" + val newSet = customMirrors.toMutableSet() + newSet.add(encoded) + customMirrors = newSet + Settings.fdroidCustomMirrors = newSet + newMirrorName = "" + newMirrorUrl = "" + urlError = null + showAddForm = false + }) { + Text(stringResource(R.string.fdroid_mirror_add_action)) + } + } + } + } else { + ListItem( + headlineContent = { + Text( + stringResource(R.string.fdroid_mirror_add), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip( + if (parsedCustomMirrors.isEmpty()) { + RoundedCornerShape(12.dp) + } else { + RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + }, + ) + .clickable { showAddForm = true }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun LatencyBadge( + url: String, + latencyResults: Map, + latencyErrors: Map, +) { + val latency = latencyResults[url] + val failed = latencyErrors[url] == true + when { + latency != null -> { + Text( + text = stringResource(R.string.fdroid_mirror_latency, latency), + style = MaterialTheme.typography.labelMedium, + color = when { + latency < 100 -> MaterialTheme.colorScheme.primary + latency < 500 -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.error + }, + ) + } + failed -> { + Text( + text = stringResource(R.string.fdroid_mirror_failed), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + } + else -> {} + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt index 3109681..f34199c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt +++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -5,7 +5,10 @@ object SettingsKey { const val SERVICE_MODE = "service_mode" const val CHECK_UPDATE_ENABLED = "check_update_enabled" const val UPDATE_CHECK_PROMPTED = "update_check_prompted" + const val UPDATE_SOURCE = "update_source" const val UPDATE_TRACK = "update_track" + const val FDROID_MIRROR_URL = "fdroid_mirror_url" + const val FDROID_CUSTOM_MIRRORS = "fdroid_custom_mirrors" const val SILENT_INSTALL_ENABLED = "silent_install_enabled" const val SILENT_INSTALL_METHOD = "silent_install_method" const val AUTO_UPDATE_ENABLED = "auto_update_enabled" diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt index 23b1d8b..ed37cb6 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -41,6 +41,7 @@ object Settings { var serviceMode by dataStore.string(SettingsKey.SERVICE_MODE) { ServiceMode.NORMAL } var startedByUser by dataStore.boolean(SettingsKey.STARTED_BY_USER) + var updateSource by dataStore.string(SettingsKey.UPDATE_SOURCE) { "github" } var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { false } var updateCheckPrompted by dataStore.boolean(SettingsKey.UPDATE_CHECK_PROMPTED) { false } var updateTrack by dataStore.string(SettingsKey.UPDATE_TRACK) { @@ -62,6 +63,8 @@ object Settings { "SHIZUKU" } } + var fdroidMirrorUrl by dataStore.string(SettingsKey.FDROID_MIRROR_URL) { "https://f-droid.org/repo" } + var fdroidCustomMirrors by dataStore.stringSet(SettingsKey.FDROID_CUSTOM_MIRRORS) { emptySet() } var autoUpdateEnabled by dataStore.boolean(SettingsKey.AUTO_UPDATE_ENABLED) { false } var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true } var disableDeprecatedWarnings by dataStore.boolean(SettingsKey.DISABLE_DEPRECATED_WARNINGS) { false } diff --git a/app/src/main/java/io/nekohasekai/sfa/update/FDroidUpdateChecker.kt b/app/src/main/java/io/nekohasekai/sfa/update/FDroidUpdateChecker.kt new file mode 100644 index 0000000..98af76f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/update/FDroidUpdateChecker.kt @@ -0,0 +1,27 @@ +package io.nekohasekai.sfa.update + +import android.content.Context +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.database.Settings + +fun checkFDroidUpdate(context: Context): UpdateInfo? { + val packageName = context.packageName + + @Suppress("DEPRECATION") + val versionCode = context.packageManager.getPackageInfo(packageName, 0).versionCode + val result = Libbox.checkFDroidUpdate( + Settings.fdroidMirrorUrl, + packageName, + versionCode, + context.cacheDir.absolutePath, + ) ?: return null + return UpdateInfo( + versionCode = result.versionCode, + versionName = result.versionName, + downloadUrl = result.downloadURL, + releaseUrl = "https://f-droid.org/packages/$packageName/", + releaseNotes = null, + isPrerelease = false, + fileSize = result.fileSize, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/update/UpdateSource.kt b/app/src/main/java/io/nekohasekai/sfa/update/UpdateSource.kt new file mode 100644 index 0000000..006ad5f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/update/UpdateSource.kt @@ -0,0 +1,14 @@ +package io.nekohasekai.sfa.update + +enum class UpdateSource { + GITHUB, + FDROID, + ; + + companion object { + fun fromString(value: String): UpdateSource = when (value.lowercase()) { + "fdroid" -> FDROID + else -> GITHUB + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt index e72e00c..db8fd2a 100644 --- a/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt +++ b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt @@ -4,6 +4,7 @@ import android.app.Activity import androidx.camera.core.ImageAnalysis import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea import io.nekohasekai.sfa.update.UpdateInfo +import io.nekohasekai.sfa.update.UpdateSource interface VendorInterface { fun checkUpdate(activity: Activity, byUser: Boolean) @@ -14,53 +15,17 @@ interface VendorInterface { onCropArea: ((QRCodeCropArea?) -> Unit)? = null, ): ImageAnalysis.Analyzer? - /** - * Check if Per-app Proxy feature is available - * @return true if available, false if disabled (e.g., for Play Store builds) - */ fun isPerAppProxyAvailable(): Boolean = true - /** - * Check if track selection is available (e.g., stable/beta) - * @return true if track selection is supported - */ - fun supportsTrackSelection(): Boolean = false + val hasCustomUpdate: Boolean get() = false + + val updateSources: List get() = listOf(UpdateSource.GITHUB) - /** - * Check for updates asynchronously - * @return UpdateInfo if update is available, null otherwise - */ fun checkUpdateAsync(): UpdateInfo? = null - /** - * Check if silent install feature is available - * @return true if silent install is supported (Other flavor only) - */ - fun supportsSilentInstall(): Boolean = false - - /** - * Check if auto update feature is available - * @return true if auto update is supported (Other flavor only) - */ - fun supportsAutoUpdate(): Boolean = false - - /** - * Schedule auto update worker - */ fun scheduleAutoUpdate() {} - /** - * Verify if the specified silent install method is available - * @param method The install method (SHIZUKU or ROOT) - * @return true if the method is available and working - */ suspend fun verifySilentInstallMethod(method: String): Boolean = false - /** - * Download and install an APK update - * @param context The context - * @param downloadUrl The URL to download the APK from - * @throws Exception if download or install fails - */ suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Unit = throw UnsupportedOperationException("Not supported in this flavor") } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8d06b38..451032f 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -199,6 +199,8 @@ 赞助 工作目录 禁用弃用警告 + 缓存大小 + 清除缓存 通知 启用通知 在通知中显示实时网速 @@ -275,6 +277,22 @@ 有新版本可用:%s 自动更新 在后台自动下载和安装更新 + 更新来源 + GitHub + F-Droid + F-Droid 镜像 + 根据延迟自动选择 + 测试中… + %d ms + 失败 + + 添加镜像 + 名称 + URL + 自定义 + 无效的 URL + 添加 + 删除 静默安装 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index b9a0b99..70e5295 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -199,6 +199,8 @@ 贊助 工作目錄 停用過時警告 + 快取大小 + 清除快取 通知 啟用通知 在通知中顯示即時網速 @@ -275,6 +277,22 @@ 有新版本可用:%s 自動更新 在背景自動下載並安裝更新 + 更新來源 + GitHub + F-Droid + F-Droid 鏡像 + 依延遲自動選擇 + 測試中… + %d ms + 失敗 + + 新增鏡像 + 名稱 + URL + 自訂 + 無效的 URL + 新增 + 刪除 靜默安裝 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9980c2..63a3787 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -199,6 +199,8 @@ Sponsor Working Directory Disable Deprecated Warnings + Cache Size + Clear Cache Notification Enable Notification Display realtime speed in notification @@ -264,6 +266,9 @@ Automatic Update Check Would you like to enable automatic update checking from **Play Store**? Would you like to enable automatic update checking from **GitHub**? + Update Source + GitHub + F-Droid Update Track Stable Beta @@ -275,6 +280,19 @@ New version available: %s Auto Update Automatically download and install updates in background + F-Droid Mirror + Auto Select by Latency + Testing… + %d ms + Failed + + Add Mirror + Name + URL + Custom + Invalid URL + Add + Delete Silent Install diff --git a/app/src/main/res/xml/cache_paths.xml b/app/src/main/res/xml/cache_paths.xml index c782c28..5f65e2f 100644 --- a/app/src/main/res/xml/cache_paths.xml +++ b/app/src/main/res/xml/cache_paths.xml @@ -1,6 +1,6 @@ - diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt index 1b0809c..32876c4 100644 --- a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -13,8 +13,10 @@ import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.update.UpdateCheckException import io.nekohasekai.sfa.update.UpdateInfo +import io.nekohasekai.sfa.update.UpdateSource import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateTrack +import io.nekohasekai.sfa.update.checkFDroidUpdate object Vendor : VendorInterface { private const val TAG = "Vendor" @@ -93,19 +95,20 @@ object Vendor : VendorInterface { onCropArea: ((QRCodeCropArea?) -> Unit)?, ): ImageAnalysis.Analyzer? = null - override fun supportsTrackSelection(): Boolean = true + override val hasCustomUpdate = true - override fun checkUpdateAsync(): UpdateInfo? { - val track = UpdateTrack.fromString(Settings.updateTrack) - return GitHubUpdateChecker().use { checker -> - checker.checkUpdate(track) + override val updateSources = listOf(UpdateSource.GITHUB, UpdateSource.FDROID) + + override fun checkUpdateAsync(): UpdateInfo? = when (UpdateSource.fromString(Settings.updateSource)) { + UpdateSource.FDROID -> checkFDroidUpdate(Application.application) + UpdateSource.GITHUB -> { + val track = UpdateTrack.fromString(Settings.updateTrack) + GitHubUpdateChecker().use { checker -> + checker.checkUpdate(track) + } } } - override fun supportsSilentInstall(): Boolean = true - - override fun supportsAutoUpdate(): Boolean = true - override fun scheduleAutoUpdate() { UpdateWorker.schedule(io.nekohasekai.sfa.Application.application) } diff --git a/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt index d34525d..835264d 100644 --- a/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -93,7 +93,7 @@ object Vendor : VendorInterface { onCropArea: ((QRCodeCropArea?) -> Unit)?, ): ImageAnalysis.Analyzer? = null - override fun supportsTrackSelection(): Boolean = true + override val hasCustomUpdate = true override fun checkUpdateAsync(): UpdateInfo? { val track = UpdateTrack.fromString(Settings.updateTrack) @@ -102,10 +102,6 @@ object Vendor : VendorInterface { } } - override fun supportsSilentInstall(): Boolean = true - - override fun supportsAutoUpdate(): Boolean = true - override fun scheduleAutoUpdate() { UpdateWorker.schedule(io.nekohasekai.sfa.Application.application) } diff --git a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt index daf9b5f..c8689ae 100644 --- a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -92,7 +92,5 @@ object Vendor : VendorInterface { } } - override fun supportsTrackSelection(): Boolean = false - override fun checkUpdateAsync(): UpdateInfo? = null }