|
|
@@ -10,6 +10,7 @@ import android.graphics.Bitmap
|
|
|
import android.graphics.Canvas
|
|
|
import android.graphics.drawable.Drawable
|
|
|
import android.net.Uri
|
|
|
+import androidx.core.content.FileProvider
|
|
|
import android.os.Handler
|
|
|
import android.os.Looper
|
|
|
import android.provider.Settings
|
|
|
@@ -17,6 +18,9 @@ import io.flutter.plugin.common.BinaryMessenger
|
|
|
import io.flutter.plugin.common.MethodCall
|
|
|
import io.flutter.plugin.common.MethodChannel
|
|
|
import java.io.ByteArrayOutputStream
|
|
|
+import java.io.File
|
|
|
+import java.net.HttpURLConnection
|
|
|
+import java.net.URL
|
|
|
import java.util.concurrent.Executors
|
|
|
|
|
|
/**
|
|
|
@@ -34,7 +38,9 @@ class LauncherBridge(private val activity: Activity) : MethodChannel.MethodCallH
|
|
|
|
|
|
private var channel: MethodChannel? = null
|
|
|
private val executor = Executors.newSingleThreadExecutor()
|
|
|
+ private val updateExecutor = Executors.newSingleThreadExecutor()
|
|
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
|
+ @Volatile private var updateBusy = false
|
|
|
@Volatile private var disposed = false
|
|
|
|
|
|
fun attach(messenger: BinaryMessenger) {
|
|
|
@@ -46,6 +52,7 @@ class LauncherBridge(private val activity: Activity) : MethodChannel.MethodCallH
|
|
|
channel?.setMethodCallHandler(null)
|
|
|
channel = null
|
|
|
executor.shutdownNow()
|
|
|
+ updateExecutor.shutdownNow()
|
|
|
}
|
|
|
|
|
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
|
|
@@ -55,10 +62,144 @@ class LauncherBridge(private val activity: Activity) : MethodChannel.MethodCallH
|
|
|
"isDefaultHome" -> result.success(isDefaultHome())
|
|
|
"requestDefaultHome" -> result.success(requestDefaultHome())
|
|
|
"openUrl" -> openUrl(call, result)
|
|
|
+ "downloadAndInstallUpdate" -> downloadAndInstallUpdate(call, result)
|
|
|
else -> result.notImplemented()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private fun downloadAndInstallUpdate(call: MethodCall, result: MethodChannel.Result) {
|
|
|
+ val url = call.argument<String>("url")
|
|
|
+ if (url.isNullOrBlank()) {
|
|
|
+ result.error("bad_arguments", "An update URL is required.", null)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!canInstallPackages()) {
|
|
|
+ openUnknownSourcesSettings()
|
|
|
+ result.success(
|
|
|
+ mapOf(
|
|
|
+ "started" to false,
|
|
|
+ "permissionRequired" to true,
|
|
|
+ "message" to "Allow CarDash to install unknown apps, then tap Update again.",
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (updateBusy) {
|
|
|
+ result.error("busy", "An update download is already running.", null)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ updateBusy = true
|
|
|
+ updateExecutor.execute {
|
|
|
+ try {
|
|
|
+ val uri = Uri.parse(url)
|
|
|
+ if (uri.scheme != "https" || uri.host != "git.krasi.net") {
|
|
|
+ throw IllegalArgumentException("Untrusted update URL.")
|
|
|
+ }
|
|
|
+ val directory = File(activity.cacheDir, "updates")
|
|
|
+ if (!directory.exists() && !directory.mkdirs()) {
|
|
|
+ throw IllegalStateException("Could not prepare update storage.")
|
|
|
+ }
|
|
|
+ val target = File(directory, "cardash-update.apk")
|
|
|
+ val partial = File(directory, "cardash-update.apk.part")
|
|
|
+ if (target.exists()) target.delete()
|
|
|
+ if (partial.exists()) partial.delete()
|
|
|
+
|
|
|
+ val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
|
|
+ instanceFollowRedirects = true
|
|
|
+ connectTimeout = 15_000
|
|
|
+ readTimeout = 90_000
|
|
|
+ setRequestProperty("User-Agent", "CarDash/${activity.packageName}")
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ val status = connection.responseCode
|
|
|
+ if (status !in 200..299) {
|
|
|
+ throw IllegalStateException("Update download failed (HTTP $status).")
|
|
|
+ }
|
|
|
+ connection.inputStream.use { input ->
|
|
|
+ partial.outputStream().buffered().use { output ->
|
|
|
+ input.copyTo(output)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ connection.disconnect()
|
|
|
+ }
|
|
|
+ if (partial.length() <= 0L) {
|
|
|
+ throw IllegalStateException("Downloaded update is empty.")
|
|
|
+ }
|
|
|
+ if (!partial.renameTo(target)) {
|
|
|
+ throw IllegalStateException("Could not finalize the update file.")
|
|
|
+ }
|
|
|
+
|
|
|
+ val archive = activity.packageManager.getPackageArchiveInfo(target.absolutePath, 0)
|
|
|
+ @Suppress("DEPRECATION")
|
|
|
+ val current = activity.packageManager.getPackageInfo(activity.packageName, 0)
|
|
|
+ if (archive == null || archive.packageName != activity.packageName) {
|
|
|
+ target.delete()
|
|
|
+ throw IllegalStateException("Downloaded file is not a CarDash package.")
|
|
|
+ }
|
|
|
+ if (archive.longVersionCode <= current.longVersionCode) {
|
|
|
+ target.delete()
|
|
|
+ throw IllegalStateException("Downloaded update is not newer than the installed app.")
|
|
|
+ }
|
|
|
+
|
|
|
+ val authority = activity.packageName + ".fileprovider"
|
|
|
+ val contentUri = FileProvider.getUriForFile(activity, authority, target)
|
|
|
+ val intent = Intent(Intent.ACTION_VIEW).apply {
|
|
|
+ setDataAndType(contentUri, "application/vnd.android.package-archive")
|
|
|
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
|
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
|
+ }
|
|
|
+ activity.runOnUiThread {
|
|
|
+ try {
|
|
|
+ activity.startActivity(intent)
|
|
|
+ result.success(
|
|
|
+ mapOf(
|
|
|
+ "started" to true,
|
|
|
+ "permissionRequired" to false,
|
|
|
+ "message" to "Download complete. Confirm installation in the system prompt.",
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ } catch (_: Throwable) {
|
|
|
+ result.success(
|
|
|
+ mapOf(
|
|
|
+ "started" to false,
|
|
|
+ "permissionRequired" to false,
|
|
|
+ "message" to "Could not open the Android installer.",
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (problem: Throwable) {
|
|
|
+ result.success(
|
|
|
+ mapOf(
|
|
|
+ "started" to false,
|
|
|
+ "permissionRequired" to false,
|
|
|
+ "message" to (problem.message ?: "Could not download the update."),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ } finally {
|
|
|
+ updateBusy = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun canInstallPackages(): Boolean {
|
|
|
+ return activity.packageManager.canRequestPackageInstalls()
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun openUnknownSourcesSettings() {
|
|
|
+ try {
|
|
|
+ activity.startActivity(
|
|
|
+ Intent(
|
|
|
+ Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
|
|
+ Uri.parse("package:${activity.packageName}"),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ } catch (_: Throwable) {
|
|
|
+ // The caller still receives permissionRequired and can retry.
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private fun openUrl(call: MethodCall, result: MethodChannel.Result) {
|
|
|
val url = call.argument<String>("url")
|
|
|
if (url.isNullOrBlank()) {
|