5 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
zarazaex69 29fd0d719d feat(ping): persist test result for selected server and update cache 2026-05-27 17:05:49 +03:00
7 changed files with 120 additions and 29 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ android {
defaultConfig {
applicationId = "xyz.zarazaex.olc"
minSdk = 24
minSdk = 30
targetSdk = 36
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.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.
*
@@ -902,7 +902,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
AppConfig.MSG_MEASURE_DELAY_SUCCESS -> {
updateTestResultAction.value = intent.getStringExtra("content")
val content = intent.getStringExtra("content")
updateTestResultAction.value = content
// Save ping for selected server so it shows in the list
val guid = MmkvManager.getSelectServer()
if (!guid.isNullOrEmpty() && content != null) {
val ms = Regex("\\d+").find(content)?.value?.toLongOrNull()
if (ms != null && ms > 0) {
MmkvManager.encodeServerTestDelayMillis(guid, ms)
refreshPingInCache(listOf(guid))
}
}
}
AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> {
@@ -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