4 Commits

Author SHA1 Message Date
neuronori a73f8d044c merge: in-app apk updater (#12) 2026-06-29 15:32:23 +00:00
neuronori c7180b45db feat(update): download and install apk in-app instead of opening browser
closes #12
2026-06-29 15:30:29 +00:00
neuronori dff7deefe3 raise minsdk to android 11 (api 30)
closes #13
2026-06-29 15:27:21 +00:00
neuronori 688da9d0e1 raise minsdk to android 11 (api 30) 2026-06-29 15:26:18 +00:00
6 changed files with 109 additions and 28 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "xyz.zarazaex.olc" applicationId = "xyz.zarazaex.olc"
minSdk = 24 minSdk = 30
targetSdk = 36 targetSdk = 36
val envVersionName = System.getenv("VERSION_NAME") val envVersionName = System.getenv("VERSION_NAME")
+1
View File
@@ -41,6 +41,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:name=".AngApplication" android:name=".AngApplication"
@@ -70,14 +70,24 @@ object UpdateCheckerManager {
} }
suspend fun downloadApk(context: Context, downloadUrl: String): File? = withContext(Dispatchers.IO) { suspend fun downloadApk(context: Context, downloadUrl: String): File? = withContext(Dispatchers.IO) {
try { // Try a direct connection first, then fall back to the local proxy
val httpPort = SettingsManager.getHttpPort() // (mirrors checkForUpdate). The proxy only listens while the VPN is up,
val connection = HttpUtil.createProxyConnection(downloadUrl, httpPort, 10000, 10000, true) // so a direct attempt is what makes updating work when disconnected.
?: throw IllegalStateException("Failed to create connection") downloadApkVia(context, downloadUrl, 0)
?: downloadApkVia(context, downloadUrl, SettingsManager.getHttpPort())
}
try { 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") val apkFile = File(context.cacheDir, "update.apk")
Log.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath}") Log.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath} (port=$httpPort)")
FileOutputStream(apkFile).use { outputStream -> FileOutputStream(apkFile).use { outputStream ->
connection.inputStream.use { inputStream -> connection.inputStream.use { inputStream ->
@@ -85,10 +95,10 @@ object UpdateCheckerManager {
} }
} }
Log.i(AppConfig.TAG, "APK download completed") Log.i(AppConfig.TAG, "APK download completed")
return@withContext apkFile apkFile
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download APK: ${e.message}") Log.e(AppConfig.TAG, "Failed to download APK (port=$httpPort): ${e.message}")
return@withContext null null
} finally { } finally {
try { try {
connection.disconnect() connection.disconnect()
@@ -96,10 +106,6 @@ object UpdateCheckerManager {
Log.e(AppConfig.TAG, "Error closing connection: ${e.message}") Log.e(AppConfig.TAG, "Error closing connection: ${e.message}")
} }
} }
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to initiate download: ${e.message}")
return@withContext null
}
} }
private fun compareVersions(version1: String, version2: String): Int { private fun compareVersions(version1: String, version2: String): Int {
@@ -1,8 +1,11 @@
package xyz.zarazaex.olc.ui package xyz.zarazaex.olc.ui
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import android.util.Log import android.util.Log
import android.widget.TextView import android.widget.TextView
import androidx.core.net.toUri
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import xyz.zarazaex.olc.AppConfig 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) { private fun showUpdateDialog(result: CheckUpdateResult) {
val message = result.releaseNotes?.let { MarkdownUtil.parseBasic(it) } ?: "" val message = result.releaseNotes?.let { MarkdownUtil.parseBasic(it) } ?: ""
val titleStr = getString(R.string.update_new_version_found, result.latestVersion) val titleStr = getString(R.string.update_new_version_found, result.latestVersion)
@@ -70,9 +107,7 @@ class CheckUpdateActivity : BaseActivity() {
.setTitle(titleStr) .setTitle(titleStr)
.setMessage(message) .setMessage(message)
.setPositiveButton(R.string.update_now) { _, _ -> .setPositiveButton(R.string.update_now) { _, _ ->
result.downloadUrl?.let { result.downloadUrl?.let { downloadAndInstall(it) }
Utils.openUri(this, it)
}
} }
.create() .create()
dialog.show() dialog.show()
@@ -15,10 +15,12 @@ import android.util.Log
import android.util.Patterns import android.util.Patterns
import android.webkit.URLUtil import android.webkit.URLUtil
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import xyz.zarazaex.olc.AppConfig import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.AppConfig.LOOPBACK import xyz.zarazaex.olc.AppConfig.LOOPBACK
import xyz.zarazaex.olc.BuildConfig import xyz.zarazaex.olc.BuildConfig
import java.io.File
import java.io.IOException import java.io.IOException
import java.net.InetAddress import java.net.InetAddress
import java.net.ServerSocket 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. * Generate a UUID.
* *
@@ -575,6 +575,9 @@
<string name="update_now">Update now</string> <string name="update_now">Update now</string>
<string name="update_check_pre_release">Check Pre-release</string> <string name="update_check_pre_release">Check Pre-release</string>
<string name="update_checking_for_update">Checking for update…</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 name="title_policy_group_type">Policy group type</string>
<string <string