merge: in-app apk updater (#12)

This commit is contained in:
neuronori
2026-06-29 15:32:23 +00:00
5 changed files with 108 additions and 27 deletions
+1
View File
@@ -41,6 +41,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".AngApplication"
@@ -70,35 +70,41 @@ object UpdateCheckerManager {
}
suspend fun downloadApk(context: Context, downloadUrl: String): File? = withContext(Dispatchers.IO) {
try {
val httpPort = SettingsManager.getHttpPort()
val connection = HttpUtil.createProxyConnection(downloadUrl, httpPort, 10000, 10000, true)
?: throw IllegalStateException("Failed to create connection")
// Try a direct connection first, then fall back to the local proxy
// (mirrors checkForUpdate). The proxy only listens while the VPN is up,
// so a direct attempt is what makes updating work when disconnected.
downloadApkVia(context, downloadUrl, 0)
?: downloadApkVia(context, downloadUrl, SettingsManager.getHttpPort())
}
try {
val apkFile = File(context.cacheDir, "update.apk")
Log.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath}")
private fun downloadApkVia(context: Context, downloadUrl: String, httpPort: Int): File? {
val connection = HttpUtil.createProxyConnection(downloadUrl, httpPort, 10000, 10000, true)
?: return null
return try {
val code = connection.responseCode
if (code !in 200..299) {
Log.e(AppConfig.TAG, "APK download failed, http $code (port=$httpPort)")
return null
}
val apkFile = File(context.cacheDir, "update.apk")
Log.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath} (port=$httpPort)")
FileOutputStream(apkFile).use { outputStream ->
connection.inputStream.use { inputStream ->
inputStream.copyTo(outputStream)
}
}
Log.i(AppConfig.TAG, "APK download completed")
return@withContext apkFile
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download APK: ${e.message}")
return@withContext null
} finally {
try {
connection.disconnect()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error closing connection: ${e.message}")
FileOutputStream(apkFile).use { outputStream ->
connection.inputStream.use { inputStream ->
inputStream.copyTo(outputStream)
}
}
Log.i(AppConfig.TAG, "APK download completed")
apkFile
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to initiate download: ${e.message}")
return@withContext null
Log.e(AppConfig.TAG, "Failed to download APK (port=$httpPort): ${e.message}")
null
} finally {
try {
connection.disconnect()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error closing connection: ${e.message}")
}
}
}
@@ -1,8 +1,11 @@
package xyz.zarazaex.olc.ui
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import android.widget.TextView
import androidx.core.net.toUri
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.lifecycle.lifecycleScope
import xyz.zarazaex.olc.AppConfig
@@ -63,6 +66,40 @@ class CheckUpdateActivity : BaseActivity() {
}
}
private fun downloadAndInstall(downloadUrl: String) {
if (!Utils.canInstallApk(this)) {
toast(R.string.update_install_permission_required)
try {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
"package:$packageName".toUri()
)
startActivity(intent)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to open install-sources settings: ${e.message}")
}
return
}
toast(R.string.update_downloading)
showLoading()
lifecycleScope.launch {
try {
val apk = UpdateCheckerManager.downloadApk(this@CheckUpdateActivity, downloadUrl)
if (apk != null && Utils.installApk(this@CheckUpdateActivity, apk)) {
return@launch
}
toastError(R.string.update_download_failed)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download/install update: ${e.message}")
toastError(e.message ?: getString(R.string.update_download_failed))
} finally {
hideLoading()
}
}
}
private fun showUpdateDialog(result: CheckUpdateResult) {
val message = result.releaseNotes?.let { MarkdownUtil.parseBasic(it) } ?: ""
val titleStr = getString(R.string.update_new_version_found, result.latestVersion)
@@ -70,9 +107,7 @@ class CheckUpdateActivity : BaseActivity() {
.setTitle(titleStr)
.setMessage(message)
.setPositiveButton(R.string.update_now) { _, _ ->
result.downloadUrl?.let {
Utils.openUri(this, it)
}
result.downloadUrl?.let { downloadAndInstall(it) }
}
.create()
dialog.show()
@@ -15,10 +15,12 @@ import android.util.Log
import android.util.Patterns
import android.webkit.URLUtil
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.AppConfig.LOOPBACK
import xyz.zarazaex.olc.BuildConfig
import java.io.File
import java.io.IOException
import java.net.InetAddress
import java.net.ServerSocket
@@ -291,6 +293,40 @@ object Utils {
}
}
/**
* Launch the system package installer for a downloaded APK file.
*
* @param context The context to use.
* @param apkFile The downloaded APK file (must live under the FileProvider paths).
* @return true if the installer intent was started, false otherwise.
*/
fun installApk(context: Context, apkFile: File): Boolean {
return try {
val uri = FileProvider.getUriForFile(
context,
"${BuildConfig.APPLICATION_ID}.cache",
apkFile
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
true
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to install APK", e)
false
}
}
/**
* Whether the app is allowed to install APKs from unknown sources.
*/
fun canInstallApk(context: Context): Boolean {
return context.packageManager.canRequestPackageInstalls()
}
/**
* Generate a UUID.
*
@@ -575,6 +575,9 @@
<string name="update_now">Update now</string>
<string name="update_check_pre_release">Check Pre-release</string>
<string name="update_checking_for_update">Checking for update…</string>
<string name="update_downloading">Downloading update…</string>
<string name="update_download_failed">Download failed</string>
<string name="update_install_permission_required">Allow installing unknown apps to update, then tap Update now again</string>
<string name="title_policy_group_type">Policy group type</string>
<string