Improve update system
This commit is contained in:
@@ -104,6 +104,7 @@ dependencies {
|
|||||||
implementation(fileTree("libs"))
|
implementation(fileTree("libs"))
|
||||||
|
|
||||||
implementation "androidx.core:core-ktx:1.16.0"
|
implementation "androidx.core:core-ktx:1.16.0"
|
||||||
|
implementation 'androidx.compose.ui:ui'
|
||||||
implementation "androidx.appcompat:appcompat:1.7.1"
|
implementation "androidx.appcompat:appcompat:1.7.1"
|
||||||
implementation "com.google.android.material:material:1.12.0"
|
implementation "com.google.android.material:material:1.12.0"
|
||||||
implementation "androidx.constraintlayout:constraintlayout:2.2.1"
|
implementation "androidx.constraintlayout:constraintlayout:2.2.1"
|
||||||
@@ -135,11 +136,17 @@ dependencies {
|
|||||||
playImplementation "com.google.android.play:app-update-ktx:2.1.0"
|
playImplementation "com.google.android.play:app-update-ktx:2.1.0"
|
||||||
playImplementation "com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1"
|
playImplementation "com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1"
|
||||||
|
|
||||||
|
// Shizuku for silent install (other flavor only)
|
||||||
|
otherImplementation 'dev.rikka.shizuku:api:13.1.5'
|
||||||
|
otherImplementation 'dev.rikka.shizuku:provider:13.1.5'
|
||||||
|
otherImplementation 'org.lsposed.hiddenapibypass:hiddenapibypass:4.3'
|
||||||
|
|
||||||
// Compose dependencies
|
// Compose dependencies
|
||||||
def composeBom = platform('androidx.compose:compose-bom:2024.09.00')
|
def composeBom = platform('androidx.compose:compose-bom:2025.01.01')
|
||||||
implementation composeBom
|
implementation composeBom
|
||||||
androidTestImplementation composeBom
|
androidTestImplementation composeBom
|
||||||
implementation 'androidx.compose.material3:material3'
|
implementation 'androidx.compose.material3:material3'
|
||||||
|
implementation 'androidx.compose.ui:ui'
|
||||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||||
@@ -151,6 +158,8 @@ dependencies {
|
|||||||
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2"
|
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2"
|
||||||
implementation "androidx.compose.runtime:runtime-livedata"
|
implementation "androidx.compose.runtime:runtime-livedata"
|
||||||
implementation "sh.calvin.reorderable:reorderable:2.3.3"
|
implementation "sh.calvin.reorderable:reorderable:2.3.3"
|
||||||
|
implementation "com.github.jeziellago:compose-markdown:0.5.4"
|
||||||
|
implementation "org.kodein.emoji:emoji-kt:2.3.0"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,18 @@ import androidx.compose.material.icons.filled.MoreVert
|
|||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import dev.jeziellago.compose.markdowntext.MarkdownText
|
||||||
import androidx.compose.material3.Badge
|
import androidx.compose.material3.Badge
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import androidx.compose.material3.BadgedBox
|
import androidx.compose.material3.BadgedBox
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -55,6 +65,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import io.nekohasekai.libbox.Libbox
|
import io.nekohasekai.libbox.Libbox
|
||||||
import io.nekohasekai.sfa.Application
|
import io.nekohasekai.sfa.Application
|
||||||
|
import io.nekohasekai.sfa.BuildConfig
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.bg.ServiceConnection
|
import io.nekohasekai.sfa.bg.ServiceConnection
|
||||||
import io.nekohasekai.sfa.bg.ServiceNotification
|
import io.nekohasekai.sfa.bg.ServiceNotification
|
||||||
@@ -134,6 +145,7 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
|
|
||||||
connection.reconnect()
|
connection.reconnect()
|
||||||
|
|
||||||
|
UpdateState.loadFromCache()
|
||||||
if (Settings.checkUpdateEnabled) {
|
if (Settings.checkUpdateEnabled) {
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
@@ -251,13 +263,126 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
}, onDismiss = { showBackgroundLocationDialog = false })
|
}, onDismiss = { showBackgroundLocationDialog = false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle update check prompt dialog (shown only once on first launch)
|
||||||
|
var showUpdateCheckPrompt by remember { mutableStateOf(!Settings.updateCheckPrompted) }
|
||||||
|
if (showUpdateCheckPrompt) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
Settings.updateCheckPrompted = true
|
||||||
|
showUpdateCheckPrompt = false
|
||||||
|
},
|
||||||
|
title = { Text(stringResource(R.string.check_update)) },
|
||||||
|
text = {
|
||||||
|
MarkdownText(
|
||||||
|
markdown = stringResource(
|
||||||
|
if (BuildConfig.FLAVOR == "play") R.string.check_update_prompt_play
|
||||||
|
else R.string.check_update_prompt_github
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
Settings.updateCheckPrompted = true
|
||||||
|
Settings.checkUpdateEnabled = true
|
||||||
|
showUpdateCheckPrompt = false
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val result = Vendor.checkUpdateAsync()
|
||||||
|
UpdateState.setUpdate(result)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
Settings.updateCheckPrompted = true
|
||||||
|
showUpdateCheckPrompt = false
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.no_thanks))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Handle update available dialog
|
// Handle update available dialog
|
||||||
val updateInfo by UpdateState.updateInfo
|
val updateInfo by UpdateState.updateInfo
|
||||||
|
val shouldShowUpdateDialog = updateInfo != null &&
|
||||||
|
updateInfo!!.versionCode > Settings.lastShownUpdateVersion
|
||||||
var showUpdateDialog by remember { mutableStateOf(true) }
|
var showUpdateDialog by remember { mutableStateOf(true) }
|
||||||
if (showUpdateDialog && updateInfo != null) {
|
|
||||||
|
// Download dialog state
|
||||||
|
var showDownloadDialog by remember { mutableStateOf(false) }
|
||||||
|
var downloadJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
var downloadError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
if (showUpdateDialog && shouldShowUpdateDialog) {
|
||||||
UpdateAvailableDialog(
|
UpdateAvailableDialog(
|
||||||
updateInfo = updateInfo!!,
|
updateInfo = updateInfo!!,
|
||||||
onDismiss = { showUpdateDialog = false },
|
onDismiss = {
|
||||||
|
Settings.lastShownUpdateVersion = updateInfo!!.versionCode
|
||||||
|
showUpdateDialog = false
|
||||||
|
},
|
||||||
|
onUpdate = {
|
||||||
|
showDownloadDialog = true
|
||||||
|
downloadError = null
|
||||||
|
downloadJob = scope.launch {
|
||||||
|
try {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
Vendor.downloadAndInstall(
|
||||||
|
this@ComposeActivity,
|
||||||
|
updateInfo!!.downloadUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (result.isFailure) {
|
||||||
|
downloadError = result.exceptionOrNull()?.message
|
||||||
|
} else {
|
||||||
|
showDownloadDialog = false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
downloadError = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download progress dialog
|
||||||
|
if (showDownloadDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text(stringResource(R.string.update)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
if (downloadError != null) {
|
||||||
|
Text(
|
||||||
|
downloadError!!,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(stringResource(R.string.downloading))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
downloadJob?.cancel()
|
||||||
|
downloadJob = null
|
||||||
|
showDownloadDialog = false
|
||||||
|
downloadError = null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package io.nekohasekai.sfa.compose.component
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -12,19 +14,26 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.jeziellago.compose.markdowntext.MarkdownText
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.update.UpdateInfo
|
import io.nekohasekai.sfa.update.UpdateInfo
|
||||||
|
import org.kodein.emoji.Emoji
|
||||||
|
import org.kodein.emoji.EmojiTemplateCatalog
|
||||||
|
import org.kodein.emoji.all
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateAvailableDialog(
|
fun UpdateAvailableDialog(
|
||||||
updateInfo: UpdateInfo,
|
updateInfo: UpdateInfo,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
onUpdate: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val emojiCatalog = remember { EmojiTemplateCatalog(Emoji.all()) }
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -37,11 +46,14 @@ fun UpdateAvailableDialog(
|
|||||||
text = stringResource(R.string.new_version_available, updateInfo.versionName),
|
text = stringResource(R.string.new_version_available, updateInfo.versionName),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!updateInfo.releaseNotes.isNullOrBlank()) {
|
if (!updateInfo.releaseNotes.isNullOrBlank()) {
|
||||||
|
val processedNotes = remember(updateInfo.releaseNotes) {
|
||||||
|
emojiCatalog.replaceShortcodes(updateInfo.releaseNotes)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
MarkdownText(
|
||||||
text = updateInfo.releaseNotes.take(500) +
|
markdown = processedNotes,
|
||||||
if (updateInfo.releaseNotes.length > 500) "..." else "",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -51,17 +63,26 @@ fun UpdateAvailableDialog(
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(updateInfo.releaseUrl))
|
|
||||||
context.startActivity(intent)
|
|
||||||
onDismiss()
|
onDismiss()
|
||||||
|
onUpdate()
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.update))
|
Text(stringResource(R.string.update))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = onDismiss) {
|
Row {
|
||||||
Text(stringResource(android.R.string.cancel))
|
TextButton(onClick = {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(updateInfo.releaseUrl))
|
||||||
|
context.startActivity(intent)
|
||||||
|
onDismiss()
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.view_release))
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(android.R.string.cancel))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package io.nekohasekai.sfa.compose.screen.settings
|
|||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings as AndroidSettings
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -12,15 +15,19 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.AdminPanelSettings
|
||||||
import androidx.compose.material.icons.outlined.Autorenew
|
import androidx.compose.material.icons.outlined.Autorenew
|
||||||
import androidx.compose.material.icons.outlined.Download
|
import androidx.compose.material.icons.outlined.Download
|
||||||
import androidx.compose.material.icons.outlined.Info
|
import androidx.compose.material.icons.outlined.Info
|
||||||
import androidx.compose.material.icons.outlined.NewReleases
|
import androidx.compose.material.icons.outlined.NewReleases
|
||||||
import androidx.compose.material.icons.outlined.Refresh
|
import androidx.compose.material.icons.outlined.Refresh
|
||||||
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
|
import androidx.compose.material.icons.outlined.SystemUpdateAlt
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Badge
|
import androidx.compose.material3.Badge
|
||||||
@@ -36,11 +43,14 @@ import androidx.compose.material3.RadioButton
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -52,11 +62,13 @@ import androidx.navigation.NavController
|
|||||||
import io.nekohasekai.sfa.BuildConfig
|
import io.nekohasekai.sfa.BuildConfig
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.database.Settings
|
import io.nekohasekai.sfa.database.Settings
|
||||||
|
import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog
|
||||||
import io.nekohasekai.sfa.update.UpdateCheckException
|
import io.nekohasekai.sfa.update.UpdateCheckException
|
||||||
import io.nekohasekai.sfa.update.UpdateState
|
import io.nekohasekai.sfa.update.UpdateState
|
||||||
import io.nekohasekai.sfa.update.UpdateTrack
|
import io.nekohasekai.sfa.update.UpdateTrack
|
||||||
import io.nekohasekai.sfa.vendor.Vendor
|
import io.nekohasekai.sfa.vendor.Vendor
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@@ -73,6 +85,38 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
var checkUpdateEnabled by remember { mutableStateOf(Settings.checkUpdateEnabled) }
|
var checkUpdateEnabled by remember { mutableStateOf(Settings.checkUpdateEnabled) }
|
||||||
var showErrorDialog by remember { mutableStateOf<Int?>(null) }
|
var showErrorDialog by remember { mutableStateOf<Int?>(null) }
|
||||||
|
|
||||||
|
var silentInstallEnabled by remember { mutableStateOf(Settings.silentInstallEnabled) }
|
||||||
|
var silentInstallMethod by remember { mutableStateOf(Settings.silentInstallMethod) }
|
||||||
|
var isMethodAvailable by remember { mutableStateOf(true) }
|
||||||
|
var autoUpdateEnabled by remember { mutableStateOf(Settings.autoUpdateEnabled) }
|
||||||
|
var showInstallMethodMenu by remember { mutableStateOf(false) }
|
||||||
|
var isVerifyingMethod by remember { mutableStateOf(false) }
|
||||||
|
var silentInstallError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
var showDownloadDialog by remember { mutableStateOf(false) }
|
||||||
|
var downloadJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
var downloadError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showUpdateAvailableDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Re-check method availability when returning from background (e.g., after granting permission)
|
||||||
|
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||||
|
if (silentInstallEnabled) {
|
||||||
|
scope.launch {
|
||||||
|
val success = withContext(Dispatchers.IO) {
|
||||||
|
Vendor.verifySilentInstallMethod(silentInstallMethod)
|
||||||
|
}
|
||||||
|
isMethodAvailable = success
|
||||||
|
silentInstallError = if (success) {
|
||||||
|
null
|
||||||
|
} else when (silentInstallMethod) {
|
||||||
|
"PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available)
|
||||||
|
"SHIZUKU" -> context.getString(R.string.shizuku_not_available)
|
||||||
|
else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showTrackDialog) {
|
if (showTrackDialog) {
|
||||||
UpdateTrackDialog(
|
UpdateTrackDialog(
|
||||||
currentTrack = currentTrack,
|
currentTrack = currentTrack,
|
||||||
@@ -100,6 +144,94 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showDownloadDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text(stringResource(R.string.update)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
if (downloadError != null) {
|
||||||
|
Text(
|
||||||
|
downloadError!!,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(stringResource(R.string.downloading))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
downloadJob?.cancel()
|
||||||
|
downloadJob = null
|
||||||
|
showDownloadDialog = false
|
||||||
|
downloadError = null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showInstallMethodMenu) {
|
||||||
|
InstallMethodDialog(
|
||||||
|
currentMethod = silentInstallMethod,
|
||||||
|
onMethodSelected = { method ->
|
||||||
|
showInstallMethodMenu = false
|
||||||
|
if (silentInstallMethod == method) return@InstallMethodDialog
|
||||||
|
silentInstallMethod = method
|
||||||
|
Settings.silentInstallMethod = method
|
||||||
|
isVerifyingMethod = true
|
||||||
|
scope.launch {
|
||||||
|
val success = withContext(Dispatchers.IO) {
|
||||||
|
Vendor.verifySilentInstallMethod(method)
|
||||||
|
}
|
||||||
|
isVerifyingMethod = false
|
||||||
|
isMethodAvailable = success
|
||||||
|
silentInstallError = if (success) {
|
||||||
|
null
|
||||||
|
} else when (method) {
|
||||||
|
"PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available)
|
||||||
|
"SHIZUKU" -> context.getString(R.string.shizuku_not_available)
|
||||||
|
else -> context.getString(R.string.silent_install_verify_failed, method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { showInstallMethodMenu = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showUpdateAvailableDialog && updateInfo != null) {
|
||||||
|
UpdateAvailableDialog(
|
||||||
|
updateInfo = updateInfo!!,
|
||||||
|
onDismiss = { showUpdateAvailableDialog = false },
|
||||||
|
onUpdate = {
|
||||||
|
showDownloadDialog = true
|
||||||
|
downloadError = null
|
||||||
|
downloadJob = scope.launch {
|
||||||
|
try {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
Vendor.downloadAndInstall(context, updateInfo!!.downloadUrl)
|
||||||
|
}
|
||||||
|
if (result.isFailure) {
|
||||||
|
downloadError = result.exceptionOrNull()?.message
|
||||||
|
} else {
|
||||||
|
showDownloadDialog = false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
downloadError = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -222,6 +354,254 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Silent Install Section (Other flavor only)
|
||||||
|
if (Vendor.supportsSilentInstall()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.silent_install_title),
|
||||||
|
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 {
|
||||||
|
// Silent Install toggle
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.silent_install),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
silentInstallError ?: stringResource(R.string.silent_install_description),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (silentInstallError != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.AdminPanelSettings,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
if (isVerifyingMethod) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Switch(
|
||||||
|
checked = silentInstallEnabled,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
silentInstallEnabled = checked
|
||||||
|
Settings.silentInstallEnabled = checked
|
||||||
|
if (checked) {
|
||||||
|
isVerifyingMethod = true
|
||||||
|
scope.launch {
|
||||||
|
val success = withContext(Dispatchers.IO) {
|
||||||
|
Vendor.verifySilentInstallMethod(silentInstallMethod)
|
||||||
|
}
|
||||||
|
isVerifyingMethod = false
|
||||||
|
isMethodAvailable = success
|
||||||
|
silentInstallError = if (success) {
|
||||||
|
null
|
||||||
|
} else when (silentInstallMethod) {
|
||||||
|
"PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available)
|
||||||
|
"SHIZUKU" -> context.getString(R.string.shizuku_not_available)
|
||||||
|
else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
silentInstallError = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
|
||||||
|
colors =
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Install Method row (when enabled)
|
||||||
|
if (silentInstallEnabled) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.silent_install_method),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
when (silentInstallMethod) {
|
||||||
|
"PACKAGE_INSTALLER" -> stringResource(R.string.install_method_package_installer)
|
||||||
|
"SHIZUKU" -> stringResource(R.string.install_method_shizuku)
|
||||||
|
"ROOT" -> stringResource(R.string.install_method_root)
|
||||||
|
else -> silentInstallMethod
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Settings,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clickable { showInstallMethodMenu = true },
|
||||||
|
colors =
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get Shizuku row (when Shizuku is selected but not available)
|
||||||
|
if (silentInstallMethod == "SHIZUKU" && !isMethodAvailable) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.get_shizuku),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.shizuku_description),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Download,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clickable {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/"))
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
colors =
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant Install Permission row (when PackageInstaller is selected but permission not granted)
|
||||||
|
if (silentInstallMethod == "PACKAGE_INSTALLER" && !isMethodAvailable) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.grant_install_permission),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.grant_install_permission_description),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Settings,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clickable {
|
||||||
|
val intent = Intent(
|
||||||
|
AndroidSettings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
|
Uri.parse("package:${context.packageName}")
|
||||||
|
)
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
colors =
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto Update toggle
|
||||||
|
if (Vendor.supportsAutoUpdate()) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.auto_update),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.auto_update_description),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.SystemUpdateAlt,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Switch(
|
||||||
|
checked = autoUpdateEnabled,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
autoUpdateEnabled = checked
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
Settings.autoUpdateEnabled = checked
|
||||||
|
Vendor.scheduleAutoUpdate()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)),
|
||||||
|
colors =
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// Action Section
|
// Action Section
|
||||||
@@ -275,21 +655,25 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.clickable(enabled = !isChecking) {
|
.clickable(enabled = !isChecking) {
|
||||||
scope.launch {
|
if (hasUpdate && updateInfo != null) {
|
||||||
UpdateState.isChecking.value = true
|
showUpdateAvailableDialog = true
|
||||||
withContext(Dispatchers.IO) {
|
} else {
|
||||||
try {
|
scope.launch {
|
||||||
val result = Vendor.checkUpdateAsync()
|
UpdateState.isChecking.value = true
|
||||||
UpdateState.setUpdate(result)
|
withContext(Dispatchers.IO) {
|
||||||
if (result == null) {
|
try {
|
||||||
showErrorDialog = R.string.no_updates_available
|
val result = Vendor.checkUpdateAsync()
|
||||||
|
UpdateState.setUpdate(result)
|
||||||
|
if (result == null) {
|
||||||
|
showErrorDialog = R.string.no_updates_available
|
||||||
|
}
|
||||||
|
} catch (_: UpdateCheckException.TrackNotSupported) {
|
||||||
|
showErrorDialog = R.string.update_track_not_supported
|
||||||
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
} catch (_: UpdateCheckException.TrackNotSupported) {
|
|
||||||
showErrorDialog = R.string.update_track_not_supported
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
}
|
||||||
|
UpdateState.isChecking.value = false
|
||||||
}
|
}
|
||||||
UpdateState.isChecking.value = false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors =
|
colors =
|
||||||
@@ -323,11 +707,7 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
Modifier
|
Modifier
|
||||||
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||||
.clickable {
|
.clickable {
|
||||||
val intent = Intent(
|
showUpdateAvailableDialog = true
|
||||||
Intent.ACTION_VIEW,
|
|
||||||
Uri.parse(updateInfo!!.releaseUrl),
|
|
||||||
)
|
|
||||||
context.startActivity(intent)
|
|
||||||
},
|
},
|
||||||
colors =
|
colors =
|
||||||
ListItemDefaults.colors(
|
ListItemDefaults.colors(
|
||||||
@@ -386,3 +766,53 @@ private fun UpdateTrackDialog(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InstallMethodDialog(
|
||||||
|
currentMethod: String,
|
||||||
|
onMethodSelected: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val methods = buildList {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
add("PACKAGE_INSTALLER" to stringResource(R.string.install_method_package_installer))
|
||||||
|
}
|
||||||
|
add("SHIZUKU" to stringResource(R.string.install_method_shizuku))
|
||||||
|
add("ROOT" to stringResource(R.string.install_method_root))
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.silent_install_method)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
methods.forEach { (value, label) ->
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable { onMethodSelected(value) }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = currentMethod == value,
|
||||||
|
onClick = { onMethodSelected(value) },
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(android.R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package io.nekohasekai.sfa.update
|
package io.nekohasekai.sfa.update
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class UpdateInfo(
|
data class UpdateInfo(
|
||||||
val versionCode: Int,
|
val versionCode: Int,
|
||||||
val versionName: String,
|
val versionName: String,
|
||||||
@@ -7,4 +12,13 @@ data class UpdateInfo(
|
|||||||
val releaseUrl: String,
|
val releaseUrl: String,
|
||||||
val releaseNotes: String?,
|
val releaseNotes: String?,
|
||||||
val isPrerelease: Boolean,
|
val isPrerelease: Boolean,
|
||||||
)
|
val fileSize: Long = 0,
|
||||||
|
) {
|
||||||
|
fun toJson(): String = Json.encodeToString(this)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromJson(json: String): UpdateInfo? = runCatching {
|
||||||
|
Json.decodeFromString<UpdateInfo>(json)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,90 @@
|
|||||||
package io.nekohasekai.sfa.update
|
package io.nekohasekai.sfa.update
|
||||||
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import io.nekohasekai.sfa.BuildConfig
|
||||||
|
import io.nekohasekai.sfa.database.Settings
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
object UpdateState {
|
object UpdateState {
|
||||||
val hasUpdate = mutableStateOf(false)
|
val hasUpdate = mutableStateOf(false)
|
||||||
val updateInfo = mutableStateOf<UpdateInfo?>(null)
|
val updateInfo = mutableStateOf<UpdateInfo?>(null)
|
||||||
val isChecking = mutableStateOf(false)
|
val isChecking = mutableStateOf(false)
|
||||||
|
|
||||||
|
val isDownloading = mutableStateOf(false)
|
||||||
|
val downloadError = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
val cachedApkFile = mutableStateOf<File?>(null)
|
||||||
|
|
||||||
|
sealed class InstallStatus {
|
||||||
|
data object Idle : InstallStatus()
|
||||||
|
data object Installing : InstallStatus()
|
||||||
|
data object Success : InstallStatus()
|
||||||
|
data class Failed(val error: String) : InstallStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
val installStatus = mutableStateOf<InstallStatus>(InstallStatus.Idle)
|
||||||
|
|
||||||
fun setUpdate(info: UpdateInfo?) {
|
fun setUpdate(info: UpdateInfo?) {
|
||||||
updateInfo.value = info
|
updateInfo.value = info
|
||||||
hasUpdate.value = info != null
|
hasUpdate.value = info != null
|
||||||
|
saveToCache(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setInstallStatus(status: InstallStatus) {
|
||||||
|
installStatus.value = status
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
hasUpdate.value = false
|
hasUpdate.value = false
|
||||||
updateInfo.value = null
|
updateInfo.value = null
|
||||||
|
isDownloading.value = false
|
||||||
|
downloadError.value = null
|
||||||
|
installStatus.value = InstallStatus.Idle
|
||||||
|
cachedApkFile.value = null
|
||||||
|
clearCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetDownload() {
|
||||||
|
isDownloading.value = false
|
||||||
|
downloadError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadFromCache() {
|
||||||
|
val json = Settings.cachedUpdateInfo
|
||||||
|
if (json.isBlank()) return
|
||||||
|
|
||||||
|
val info = UpdateInfo.fromJson(json) ?: return
|
||||||
|
if (info.versionCode <= BuildConfig.VERSION_CODE) {
|
||||||
|
clearCache()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInfo.value = info
|
||||||
|
hasUpdate.value = true
|
||||||
|
|
||||||
|
val apkPath = Settings.cachedApkPath
|
||||||
|
if (apkPath.isNotBlank()) {
|
||||||
|
val apkFile = File(apkPath)
|
||||||
|
if (apkFile.exists() && apkFile.length() > 0) {
|
||||||
|
cachedApkFile.value = apkFile
|
||||||
|
} else {
|
||||||
|
Settings.cachedApkPath = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveToCache(info: UpdateInfo?) {
|
||||||
|
Settings.cachedUpdateInfo = info?.toJson() ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveApkPath(file: File) {
|
||||||
|
Settings.cachedApkPath = file.absolutePath
|
||||||
|
cachedApkFile.value = file
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearCache() {
|
||||||
|
Settings.cachedUpdateInfo = ""
|
||||||
|
Settings.cachedApkPath = ""
|
||||||
|
Settings.lastShownUpdateVersion = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,4 +32,37 @@ interface VendorInterface {
|
|||||||
* @return UpdateInfo if update is available, null otherwise
|
* @return UpdateInfo if update is available, null otherwise
|
||||||
*/
|
*/
|
||||||
fun checkUpdateAsync(): UpdateInfo? = null
|
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
|
||||||
|
* @return Result indicating success or failure
|
||||||
|
*/
|
||||||
|
suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Result<Unit> =
|
||||||
|
Result.failure(UnsupportedOperationException("Not supported in this flavor"))
|
||||||
}
|
}
|
||||||
|
|||||||
29
app/src/other/AndroidManifest.xml
Normal file
29
app/src/other/AndroidManifest.xml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||||
|
|
||||||
|
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
|
||||||
|
|
||||||
|
<uses-sdk tools:overrideLibrary="rikka.shizuku.provider,rikka.shizuku.api,rikka.shizuku.aidl,rikka.shizuku.shared" />
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<provider
|
||||||
|
android:name="rikka.shizuku.ShizukuProvider"
|
||||||
|
android:authorities="${applicationId}.shizuku"
|
||||||
|
android:exported="true"
|
||||||
|
android:multiprocess="false"
|
||||||
|
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".vendor.InstallResultReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="io.nekohasekai.sfa.INSTALL_COMPLETE" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
9
app/src/other/java/android/content/IIntentReceiver.java
Normal file
9
app/src/other/java/android/content/IIntentReceiver.java
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package android.content;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.IInterface;
|
||||||
|
|
||||||
|
public interface IIntentReceiver extends IInterface {
|
||||||
|
void performReceive(Intent intent, int resultCode, String data, Bundle extras,
|
||||||
|
boolean ordered, boolean sticky, int sendingUser);
|
||||||
|
}
|
||||||
23
app/src/other/java/android/content/IIntentSender.java
Normal file
23
app/src/other/java/android/content/IIntentSender.java
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package android.content;
|
||||||
|
|
||||||
|
import android.os.Binder;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.os.IInterface;
|
||||||
|
|
||||||
|
public interface IIntentSender extends IInterface {
|
||||||
|
|
||||||
|
void send(int code, Intent intent, String resolvedType, IBinder whitelistToken,
|
||||||
|
IIntentReceiver finishedReceiver, String requiredPermission, Bundle options);
|
||||||
|
|
||||||
|
abstract class Stub extends Binder implements IIntentSender {
|
||||||
|
public static IIntentSender asInterface(IBinder binder) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IBinder asBinder() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/src/other/java/android/content/pm/IPackageInstaller.java
Normal file
21
app/src/other/java/android/content/pm/IPackageInstaller.java
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package android.content.pm;
|
||||||
|
|
||||||
|
import android.os.Binder;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.os.IInterface;
|
||||||
|
import android.os.RemoteException;
|
||||||
|
|
||||||
|
public interface IPackageInstaller extends IInterface {
|
||||||
|
|
||||||
|
int createSession(PackageInstaller.SessionParams params, String installerPackageName, String installerAttributionTag, int userId) throws RemoteException;
|
||||||
|
|
||||||
|
IPackageInstallerSession openSession(int sessionId) throws RemoteException;
|
||||||
|
|
||||||
|
void abandonSession(int sessionId) throws RemoteException;
|
||||||
|
|
||||||
|
abstract class Stub extends Binder implements IPackageInstaller {
|
||||||
|
public static IPackageInstaller asInterface(IBinder binder) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package android.content.pm;
|
||||||
|
|
||||||
|
import android.os.Binder;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.os.IInterface;
|
||||||
|
|
||||||
|
public interface IPackageInstallerSession extends IInterface {
|
||||||
|
|
||||||
|
abstract class Stub extends Binder implements IPackageInstallerSession {
|
||||||
|
public static IPackageInstallerSession asInterface(IBinder binder) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/src/other/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt
vendored
Normal file
43
app/src/other/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package io.nekohasekai.sfa.vendor
|
||||||
|
|
||||||
|
import io.nekohasekai.libbox.Libbox
|
||||||
|
import io.nekohasekai.sfa.Application
|
||||||
|
import io.nekohasekai.sfa.update.UpdateState
|
||||||
|
import io.nekohasekai.sfa.utils.HTTPClient
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.Closeable
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class ApkDownloader : Closeable {
|
||||||
|
private val client = Libbox.newHTTPClient().apply {
|
||||||
|
modernTLS()
|
||||||
|
keepAlive()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun download(url: String): File = withContext(Dispatchers.IO) {
|
||||||
|
val cacheDir = File(Application.application.cacheDir, "updates")
|
||||||
|
cacheDir.mkdirs()
|
||||||
|
val apkFile = File(cacheDir, "update.apk")
|
||||||
|
|
||||||
|
if (apkFile.exists()) apkFile.delete()
|
||||||
|
|
||||||
|
val request = client.newRequest()
|
||||||
|
request.setUserAgent(HTTPClient.userAgent)
|
||||||
|
request.setURL(url)
|
||||||
|
|
||||||
|
val response = request.execute()
|
||||||
|
response.writeTo(apkFile.absolutePath)
|
||||||
|
|
||||||
|
if (!apkFile.exists() || apkFile.length() == 0L) {
|
||||||
|
throw Exception("Download failed: empty file")
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateState.saveApkPath(apkFile)
|
||||||
|
apkFile
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/src/other/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt
vendored
Normal file
84
app/src/other/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt
vendored
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package io.nekohasekai.sfa.vendor
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.os.Build
|
||||||
|
import io.nekohasekai.sfa.database.Settings
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
|
enum class InstallMethod {
|
||||||
|
PACKAGE_INSTALLER,
|
||||||
|
SHIZUKU,
|
||||||
|
ROOT,
|
||||||
|
}
|
||||||
|
|
||||||
|
object ApkInstaller {
|
||||||
|
|
||||||
|
fun getConfiguredMethod(): InstallMethod {
|
||||||
|
return if (Settings.silentInstallEnabled) {
|
||||||
|
InstallMethod.valueOf(Settings.silentInstallMethod)
|
||||||
|
} else {
|
||||||
|
InstallMethod.PACKAGE_INSTALLER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun install(context: Context, apkFile: File, method: InstallMethod = getConfiguredMethod()): Result<Unit> {
|
||||||
|
return when (method) {
|
||||||
|
InstallMethod.SHIZUKU -> ShizukuInstaller.install(apkFile)
|
||||||
|
InstallMethod.ROOT -> RootInstaller.install(apkFile)
|
||||||
|
InstallMethod.PACKAGE_INSTALLER -> installWithPackageInstaller(context, apkFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canSystemSilentInstall(): Boolean {
|
||||||
|
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun canSilentInstall(): Boolean {
|
||||||
|
val method = getConfiguredMethod()
|
||||||
|
return when (method) {
|
||||||
|
InstallMethod.PACKAGE_INSTALLER -> canSystemSilentInstall()
|
||||||
|
InstallMethod.SHIZUKU -> ShizukuInstaller.isAvailable() && ShizukuInstaller.checkPermission()
|
||||||
|
InstallMethod.ROOT -> RootInstaller.checkAccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installWithPackageInstaller(context: Context, apkFile: File): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
val packageInstaller = context.packageManager.packageInstaller
|
||||||
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
|
params.setAppPackageName(context.packageName)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||||
|
}
|
||||||
|
|
||||||
|
val sessionId = packageInstaller.createSession(params)
|
||||||
|
packageInstaller.openSession(sessionId).use { session ->
|
||||||
|
session.openWrite("update.apk", 0, apkFile.length()).use { outputStream ->
|
||||||
|
FileInputStream(apkFile).use { inputStream ->
|
||||||
|
inputStream.copyTo(outputStream)
|
||||||
|
}
|
||||||
|
session.fsync(outputStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(context, InstallResultReceiver::class.java).apply {
|
||||||
|
action = InstallResultReceiver.ACTION_INSTALL_COMPLETE
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
sessionId,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
session.commit(pendingIntent.intentSender)
|
||||||
|
}
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ class GitHubUpdateChecker : Closeable {
|
|||||||
releaseUrl = release.htmlUrl,
|
releaseUrl = release.htmlUrl,
|
||||||
releaseNotes = release.body,
|
releaseNotes = release.body,
|
||||||
isPrerelease = release.prerelease,
|
isPrerelease = release.prerelease,
|
||||||
|
fileSize = apkAsset?.size ?: 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
47
app/src/other/java/io/nekohasekai/sfa/vendor/InstallResultReceiver.kt
vendored
Normal file
47
app/src/other/java/io/nekohasekai/sfa/vendor/InstallResultReceiver.kt
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package io.nekohasekai.sfa.vendor
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.util.Log
|
||||||
|
import io.nekohasekai.sfa.update.UpdateState
|
||||||
|
|
||||||
|
class InstallResultReceiver : BroadcastReceiver() {
|
||||||
|
companion object {
|
||||||
|
const val ACTION_INSTALL_COMPLETE = "io.nekohasekai.sfa.INSTALL_COMPLETE"
|
||||||
|
private const val TAG = "InstallResultReceiver"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != ACTION_INSTALL_COMPLETE) return
|
||||||
|
|
||||||
|
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
|
||||||
|
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||||
|
|
||||||
|
Log.d(TAG, "Install result: status=$status, message=$message")
|
||||||
|
|
||||||
|
when (status) {
|
||||||
|
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||||
|
val confirmIntent = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||||
|
}
|
||||||
|
confirmIntent?.let {
|
||||||
|
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
context.startActivity(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PackageInstaller.STATUS_SUCCESS -> {
|
||||||
|
Log.d(TAG, "Installation successful")
|
||||||
|
UpdateState.setInstallStatus(UpdateState.InstallStatus.Success)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.e(TAG, "Installation failed: $status - $message")
|
||||||
|
UpdateState.setInstallStatus(UpdateState.InstallStatus.Failed(message ?: "Unknown error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/src/other/java/io/nekohasekai/sfa/vendor/RootInstaller.kt
vendored
Normal file
39
app/src/other/java/io/nekohasekai/sfa/vendor/RootInstaller.kt
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package io.nekohasekai.sfa.vendor
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.BufferedWriter
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
|
||||||
|
object RootInstaller {
|
||||||
|
|
||||||
|
suspend fun checkAccess(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val process = Runtime.getRuntime().exec("su -c echo test")
|
||||||
|
val exitCode = process.waitFor()
|
||||||
|
exitCode == 0
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun install(apkFile: File): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "pm install -r \"${apkFile.absolutePath}\""))
|
||||||
|
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
||||||
|
val output = reader.readText()
|
||||||
|
val exitCode = process.waitFor()
|
||||||
|
|
||||||
|
if (exitCode == 0 && output.contains("Success")) {
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("Installation failed: $output"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
185
app/src/other/java/io/nekohasekai/sfa/vendor/ShizukuInstaller.kt
vendored
Normal file
185
app/src/other/java/io/nekohasekai/sfa/vendor/ShizukuInstaller.kt
vendored
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package io.nekohasekai.sfa.vendor
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentSender
|
||||||
|
import android.content.pm.IPackageInstaller
|
||||||
|
import android.content.pm.IPackageInstallerSession
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Process
|
||||||
|
import io.nekohasekai.sfa.vendor.hidden.IPackageManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
||||||
|
import rikka.shizuku.Shizuku
|
||||||
|
import rikka.shizuku.ShizukuBinderWrapper
|
||||||
|
import rikka.shizuku.SystemServiceHelper
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import android.content.IIntentSender
|
||||||
|
|
||||||
|
object ShizukuInstaller {
|
||||||
|
|
||||||
|
private const val SHIZUKU_PERMISSION_REQUEST_CODE = 1001
|
||||||
|
|
||||||
|
fun isAvailable(): Boolean {
|
||||||
|
return try {
|
||||||
|
Shizuku.pingBinder()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkPermission(): Boolean {
|
||||||
|
return try {
|
||||||
|
if (Shizuku.isPreV11()) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestPermission() {
|
||||||
|
if (!Shizuku.isPreV11()) {
|
||||||
|
Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isRunningAsRoot(): Boolean {
|
||||||
|
return try {
|
||||||
|
Shizuku.getUid() == 0
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPackageInstaller(): IPackageInstaller {
|
||||||
|
val packageManagerBinder = SystemServiceHelper.getSystemService("package")
|
||||||
|
val packageManager = IPackageManager.Stub.asInterface(ShizukuBinderWrapper(packageManagerBinder))
|
||||||
|
val installerBinder = packageManager.packageInstaller.asBinder()
|
||||||
|
return IPackageInstaller.Stub.asInterface(ShizukuBinderWrapper(installerBinder))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPackageInstaller(
|
||||||
|
installer: IPackageInstaller,
|
||||||
|
installerPackageName: String,
|
||||||
|
installerAttributionTag: String?,
|
||||||
|
userId: Int
|
||||||
|
): PackageInstaller {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
return PackageInstaller::class.java
|
||||||
|
.getConstructor(
|
||||||
|
IPackageInstaller::class.java,
|
||||||
|
String::class.java,
|
||||||
|
String::class.java,
|
||||||
|
Int::class.javaPrimitiveType
|
||||||
|
)
|
||||||
|
.newInstance(installer, installerPackageName, installerAttributionTag, userId)
|
||||||
|
} else {
|
||||||
|
return PackageInstaller::class.java
|
||||||
|
.getConstructor(
|
||||||
|
IPackageInstaller::class.java,
|
||||||
|
String::class.java,
|
||||||
|
Int::class.javaPrimitiveType
|
||||||
|
)
|
||||||
|
.newInstance(installer, installerPackageName, userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createSession(session: IPackageInstallerSession): PackageInstaller.Session {
|
||||||
|
return PackageInstaller.Session::class.java
|
||||||
|
.getConstructor(IPackageInstallerSession::class.java)
|
||||||
|
.newInstance(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createIntentSender(onResult: (Intent) -> Unit): IntentSender {
|
||||||
|
val sender = object : IIntentSender.Stub() {
|
||||||
|
override fun send(
|
||||||
|
code: Int,
|
||||||
|
intent: Intent,
|
||||||
|
resolvedType: String?,
|
||||||
|
whitelistToken: android.os.IBinder?,
|
||||||
|
finishedReceiver: android.content.IIntentReceiver?,
|
||||||
|
requiredPermission: String?,
|
||||||
|
options: android.os.Bundle?
|
||||||
|
) {
|
||||||
|
onResult(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return IntentSender::class.java
|
||||||
|
.getConstructor(IIntentSender::class.java)
|
||||||
|
.newInstance(sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun install(apkFile: File): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
HiddenApiBypass.addHiddenApiExemptions("")
|
||||||
|
}
|
||||||
|
|
||||||
|
val iPackageInstaller = getPackageInstaller()
|
||||||
|
val isRoot = isRunningAsRoot()
|
||||||
|
|
||||||
|
val installerPackageName = if (isRoot) "io.nekohasekai.sfa" else "com.android.shell"
|
||||||
|
val installerAttributionTag: String? = null
|
||||||
|
val userId = if (isRoot) Process.myUserHandle().hashCode() else 0
|
||||||
|
|
||||||
|
val packageInstaller = createPackageInstaller(
|
||||||
|
iPackageInstaller,
|
||||||
|
installerPackageName,
|
||||||
|
installerAttributionTag,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
|
||||||
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
|
val sessionId = packageInstaller.createSession(params)
|
||||||
|
|
||||||
|
val iSession = IPackageInstallerSession.Stub.asInterface(
|
||||||
|
ShizukuBinderWrapper(iPackageInstaller.openSession(sessionId).asBinder())
|
||||||
|
)
|
||||||
|
val session = createSession(iSession)
|
||||||
|
|
||||||
|
try {
|
||||||
|
FileInputStream(apkFile).use { inputStream ->
|
||||||
|
session.openWrite("base.apk", 0, apkFile.length()).use { outputStream ->
|
||||||
|
inputStream.copyTo(outputStream)
|
||||||
|
session.fsync(outputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val resultIntent = arrayOfNulls<Intent>(1)
|
||||||
|
val latch = CountDownLatch(1)
|
||||||
|
|
||||||
|
val intentSender = createIntentSender { intent ->
|
||||||
|
resultIntent[0] = intent
|
||||||
|
latch.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
session.commit(intentSender)
|
||||||
|
latch.await(60, TimeUnit.SECONDS)
|
||||||
|
|
||||||
|
val intent = resultIntent[0]
|
||||||
|
?: return@withContext Result.failure(Exception("Installation timed out"))
|
||||||
|
|
||||||
|
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
|
||||||
|
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||||
|
|
||||||
|
if (status == PackageInstaller.STATUS_SUCCESS) {
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("Installation failed: $status - $message"))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/src/other/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt
vendored
Normal file
97
app/src/other/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt
vendored
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package io.nekohasekai.sfa.vendor
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import io.nekohasekai.sfa.database.Settings
|
||||||
|
import io.nekohasekai.sfa.update.UpdateState
|
||||||
|
import io.nekohasekai.sfa.update.UpdateTrack
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class UpdateWorker(
|
||||||
|
private val appContext: Context,
|
||||||
|
params: WorkerParameters
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val WORK_NAME = "AutoUpdate"
|
||||||
|
private const val TAG = "UpdateWorker"
|
||||||
|
|
||||||
|
fun schedule(context: Context) {
|
||||||
|
if (!Settings.autoUpdateEnabled) {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||||
|
Log.d(TAG, "Auto update disabled, cancelled scheduled work")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.setRequiresBatteryNotLow(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val workRequest = PeriodicWorkRequestBuilder<UpdateWorker>(
|
||||||
|
24, TimeUnit.HOURS
|
||||||
|
)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.HOURS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
workRequest
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Auto update scheduled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
if (!Settings.autoUpdateEnabled) {
|
||||||
|
Log.d(TAG, "Auto update disabled, skipping")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Checking for updates...")
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val track = UpdateTrack.fromString(Settings.updateTrack)
|
||||||
|
val updateInfo = GitHubUpdateChecker().use { it.checkUpdate(track) }
|
||||||
|
|
||||||
|
if (updateInfo == null) {
|
||||||
|
Log.d(TAG, "No update available")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Update available: ${updateInfo.versionName}")
|
||||||
|
UpdateState.setUpdate(updateInfo)
|
||||||
|
|
||||||
|
if (Settings.silentInstallEnabled && ApkInstaller.canSilentInstall()) {
|
||||||
|
Log.d(TAG, "Downloading update...")
|
||||||
|
val apkFile = ApkDownloader().use { it.download(updateInfo.downloadUrl) }
|
||||||
|
|
||||||
|
Log.d(TAG, "Installing update...")
|
||||||
|
val result = ApkInstaller.install(appContext, apkFile)
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
Log.d(TAG, "Update installed successfully")
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Update installation failed", result.exceptionOrNull())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Silent install not available, update will be shown on next app launch")
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Auto update failed", e)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,12 @@ import android.net.Uri
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.camera.core.ImageAnalysis
|
import androidx.camera.core.ImageAnalysis
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import io.nekohasekai.sfa.Application
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.database.Settings
|
import io.nekohasekai.sfa.database.Settings
|
||||||
import io.nekohasekai.sfa.update.UpdateCheckException
|
import io.nekohasekai.sfa.update.UpdateCheckException
|
||||||
import io.nekohasekai.sfa.update.UpdateInfo
|
import io.nekohasekai.sfa.update.UpdateInfo
|
||||||
|
import io.nekohasekai.sfa.update.UpdateState
|
||||||
import io.nekohasekai.sfa.update.UpdateTrack
|
import io.nekohasekai.sfa.update.UpdateTrack
|
||||||
|
|
||||||
object Vendor : VendorInterface {
|
object Vendor : VendorInterface {
|
||||||
@@ -107,4 +109,51 @@ object Vendor : VendorInterface {
|
|||||||
checker.checkUpdate(track)
|
checker.checkUpdate(track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun supportsSilentInstall(): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun supportsAutoUpdate(): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun scheduleAutoUpdate() {
|
||||||
|
UpdateWorker.schedule(io.nekohasekai.sfa.Application.application)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun verifySilentInstallMethod(method: String): Boolean {
|
||||||
|
return when (method) {
|
||||||
|
"PACKAGE_INSTALLER" -> {
|
||||||
|
ApkInstaller.canSystemSilentInstall() &&
|
||||||
|
Application.application.packageManager.canRequestPackageInstalls()
|
||||||
|
}
|
||||||
|
"SHIZUKU" -> {
|
||||||
|
if (!ShizukuInstaller.isAvailable()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!ShizukuInstaller.checkPermission()) {
|
||||||
|
ShizukuInstaller.requestPermission()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"ROOT" -> RootInstaller.checkAccess()
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
val cachedApk = UpdateState.cachedApkFile.value
|
||||||
|
val apkFile = if (cachedApk != null && cachedApk.exists() && cachedApk.length() > 0) {
|
||||||
|
cachedApk
|
||||||
|
} else {
|
||||||
|
ApkDownloader().use { it.download(downloadUrl) }
|
||||||
|
}
|
||||||
|
ApkInstaller.install(context, apkFile)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
app/src/other/java/io/nekohasekai/sfa/vendor/hidden/IPackageManager.java
vendored
Normal file
19
app/src/other/java/io/nekohasekai/sfa/vendor/hidden/IPackageManager.java
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package io.nekohasekai.sfa.vendor.hidden;
|
||||||
|
|
||||||
|
import android.os.Binder;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.os.IInterface;
|
||||||
|
import android.os.RemoteException;
|
||||||
|
|
||||||
|
import android.content.pm.IPackageInstaller;
|
||||||
|
|
||||||
|
public interface IPackageManager extends IInterface {
|
||||||
|
|
||||||
|
IPackageInstaller getPackageInstaller() throws RemoteException;
|
||||||
|
|
||||||
|
abstract class Stub extends Binder implements IPackageManager {
|
||||||
|
public static IPackageManager asInterface(IBinder binder) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user