Add F-Droid as update check sources

This commit is contained in:
世界
2026-03-11 16:45:27 +08:00
parent 0d1ee7aa80
commit 868c1de2ff
16 changed files with 874 additions and 76 deletions

View File

@@ -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 updateInfo = when (UpdateSource.fromString(Settings.updateSource)) {
UpdateSource.FDROID -> checkFDroidUpdate(appContext)
UpdateSource.GITHUB -> {
val track = UpdateTrack.fromString(Settings.updateTrack)
val updateInfo = GitHubUpdateChecker().use { it.checkUpdate(track) }
GitHubUpdateChecker().use { it.checkUpdate(track) }
}
}
if (updateInfo == null) {
Log.d(TAG, "No update available")

View File

@@ -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,

View File

@@ -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<Int?>(null) }
var showErrorDialog by remember { mutableStateOf<String?>(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,10 +745,14 @@ fun AppSettingsScreen(navController: NavController) {
)
},
supportingContent = {
val trackName = when (UpdateTrack.fromString(currentTrack)) {
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)
},
leadingContent = {
@@ -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<Locale> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val localeConfig = LocaleConfig(context)

View File

@@ -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<String, Int>() }
val latencyErrors = remember { mutableStateMapOf<String, Boolean>() }
var showAddForm by remember { mutableStateOf(false) }
var newMirrorName by remember { mutableStateOf("") }
var newMirrorUrl by remember { mutableStateOf("") }
var urlError by remember { mutableStateOf<String?>(null) }
val invalidUrlMessage = stringResource(R.string.fdroid_mirror_invalid_url)
var customMirrors by remember { mutableStateOf(Settings.fdroidCustomMirrors) }
val builtinMirrors = remember {
val mirrors = mutableListOf<MirrorEntry>()
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<String, Int>,
latencyErrors: Map<String, Boolean>,
) {
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 -> {}
}
}

View File

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

View File

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

View File

@@ -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,
)
}

View File

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

View File

@@ -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<UpdateSource> 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")
}

View File

@@ -199,6 +199,8 @@
<string name="sponsor">赞助</string>
<string name="working_directory">工作目录</string>
<string name="disable_deprecated_warnings">禁用弃用警告</string>
<string name="cache_size">缓存大小</string>
<string name="clear_cache">清除缓存</string>
<string name="notification_settings">通知</string>
<string name="enable_notification">启用通知</string>
<string name="dynamic_notification">在通知中显示实时网速</string>
@@ -275,6 +277,22 @@
<string name="new_version_available">有新版本可用:%s</string>
<string name="auto_update">自动更新</string>
<string name="auto_update_description">在后台自动下载和安装更新</string>
<string name="update_source">更新来源</string>
<string name="update_source_github">GitHub</string>
<string name="update_source_fdroid">F-Droid</string>
<string name="fdroid_mirror">F-Droid 镜像</string>
<string name="fdroid_mirror_test_all">根据延迟自动选择</string>
<string name="fdroid_mirror_testing">测试中…</string>
<string name="fdroid_mirror_latency">%d ms</string>
<string name="fdroid_mirror_failed">失败</string>
<string name="fdroid_mirror_untested"></string>
<string name="fdroid_mirror_add">添加镜像</string>
<string name="fdroid_mirror_name_hint">名称</string>
<string name="fdroid_mirror_url_hint">URL</string>
<string name="fdroid_mirror_custom">自定义</string>
<string name="fdroid_mirror_invalid_url">无效的 URL</string>
<string name="fdroid_mirror_add_action">添加</string>
<string name="fdroid_mirror_delete">删除</string>
<!-- Silent Install -->
<string name="silent_install">静默安装</string>

View File

@@ -199,6 +199,8 @@
<string name="sponsor">贊助</string>
<string name="working_directory">工作目錄</string>
<string name="disable_deprecated_warnings">停用過時警告</string>
<string name="cache_size">快取大小</string>
<string name="clear_cache">清除快取</string>
<string name="notification_settings">通知</string>
<string name="enable_notification">啟用通知</string>
<string name="dynamic_notification">在通知中顯示即時網速</string>
@@ -275,6 +277,22 @@
<string name="new_version_available">有新版本可用:%s</string>
<string name="auto_update">自動更新</string>
<string name="auto_update_description">在背景自動下載並安裝更新</string>
<string name="update_source">更新來源</string>
<string name="update_source_github">GitHub</string>
<string name="update_source_fdroid">F-Droid</string>
<string name="fdroid_mirror">F-Droid 鏡像</string>
<string name="fdroid_mirror_test_all">依延遲自動選擇</string>
<string name="fdroid_mirror_testing">測試中…</string>
<string name="fdroid_mirror_latency">%d ms</string>
<string name="fdroid_mirror_failed">失敗</string>
<string name="fdroid_mirror_untested"></string>
<string name="fdroid_mirror_add">新增鏡像</string>
<string name="fdroid_mirror_name_hint">名稱</string>
<string name="fdroid_mirror_url_hint">URL</string>
<string name="fdroid_mirror_custom">自訂</string>
<string name="fdroid_mirror_invalid_url">無效的 URL</string>
<string name="fdroid_mirror_add_action">新增</string>
<string name="fdroid_mirror_delete">刪除</string>
<!-- Silent Install -->
<string name="silent_install">靜默安裝</string>

View File

@@ -199,6 +199,8 @@
<string name="sponsor">Sponsor</string>
<string name="working_directory">Working Directory</string>
<string name="disable_deprecated_warnings">Disable Deprecated Warnings</string>
<string name="cache_size">Cache Size</string>
<string name="clear_cache">Clear Cache</string>
<string name="notification_settings">Notification</string>
<string name="enable_notification">Enable Notification</string>
<string name="dynamic_notification">Display realtime speed in notification</string>
@@ -264,6 +266,9 @@
<string name="check_update_automatic">Automatic Update Check</string>
<string name="check_update_prompt_play">Would you like to enable automatic update checking from **Play Store**?</string>
<string name="check_update_prompt_github">Would you like to enable automatic update checking from **GitHub**?</string>
<string name="update_source">Update Source</string>
<string name="update_source_github">GitHub</string>
<string name="update_source_fdroid">F-Droid</string>
<string name="update_track">Update Track</string>
<string name="update_track_stable">Stable</string>
<string name="update_track_beta">Beta</string>
@@ -275,6 +280,19 @@
<string name="new_version_available">New version available: %s</string>
<string name="auto_update">Auto Update</string>
<string name="auto_update_description">Automatically download and install updates in background</string>
<string name="fdroid_mirror">F-Droid Mirror</string>
<string name="fdroid_mirror_test_all">Auto Select by Latency</string>
<string name="fdroid_mirror_testing">Testing…</string>
<string name="fdroid_mirror_latency">%d ms</string>
<string name="fdroid_mirror_failed">Failed</string>
<string name="fdroid_mirror_untested"></string>
<string name="fdroid_mirror_add">Add Mirror</string>
<string name="fdroid_mirror_name_hint">Name</string>
<string name="fdroid_mirror_url_hint">URL</string>
<string name="fdroid_mirror_custom">Custom</string>
<string name="fdroid_mirror_invalid_url">Invalid URL</string>
<string name="fdroid_mirror_add_action">Add</string>
<string name="fdroid_mirror_delete">Delete</string>
<!-- Silent Install -->
<string name="silent_install">Silent Install</string>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="cache"
<external-files-path
name="external_files"
path="/" />
</paths>

View File

@@ -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,18 +95,19 @@ object Vendor : VendorInterface {
onCropArea: ((QRCodeCropArea?) -> Unit)?,
): ImageAnalysis.Analyzer? = null
override fun supportsTrackSelection(): Boolean = true
override val hasCustomUpdate = true
override fun checkUpdateAsync(): UpdateInfo? {
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)
return GitHubUpdateChecker().use { checker ->
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)

View File

@@ -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)
}

View File

@@ -92,7 +92,5 @@ object Vendor : VendorInterface {
}
}
override fun supportsTrackSelection(): Boolean = false
override fun checkUpdateAsync(): UpdateInfo? = null
}