From 5d60a12030b3b94adaf6ac64f4b668a1bb2853c9 Mon Sep 17 00:00:00 2001 From: nift4 Date: Fri, 23 Aug 2024 12:27:10 +0200 Subject: [PATCH] migrate to new download system in legacy flows part1 --- .../java/org/andbootmgr/app/CreatePartFlow.kt | 307 +++--------------- .../java/org/andbootmgr/app/DroidBootFlow.kt | 86 +++-- .../java/org/andbootmgr/app/WizardActivity.kt | 172 +++++++++- .../java/org/andbootmgr/app/util/AbmOkHttp.kt | 109 +++++++ app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values/strings.xml | 3 +- 6 files changed, 397 insertions(+), 281 deletions(-) create mode 100644 app/src/main/java/org/andbootmgr/app/util/AbmOkHttp.kt diff --git a/app/src/main/java/org/andbootmgr/app/CreatePartFlow.kt b/app/src/main/java/org/andbootmgr/app/CreatePartFlow.kt index c6a30385..73111141 100644 --- a/app/src/main/java/org/andbootmgr/app/CreatePartFlow.kt +++ b/app/src/main/java/org/andbootmgr/app/CreatePartFlow.kt @@ -1,16 +1,41 @@ package org.andbootmgr.app -import android.net.Uri import android.util.Log -import android.widget.Toast import androidx.collection.ArrayMap import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RangeSlider +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter @@ -21,12 +46,8 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.io.SuFile -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import okhttp3.* -import okio.* import org.andbootmgr.app.CreatePartDataHolder.Part import org.andbootmgr.app.util.ConfigFile import org.andbootmgr.app.util.SDUtils @@ -35,12 +56,9 @@ import org.andbootmgr.app.util.Terminal import org.json.JSONObject import org.json.JSONTokener import java.io.File -import java.io.FileInputStream import java.io.FileNotFoundException -import java.io.InputStream import java.math.BigDecimal import java.net.URL -import java.util.concurrent.TimeUnit class CreatePartFlow(private val desiredStartSector: Long): WizardFlow() { override fun get(vm: WizardActivityState): List { @@ -64,7 +82,7 @@ class CreatePartFlow(private val desiredStartSector: Long): WizardFlow() { NavButton(vm.activity.getString(R.string.cancel)) { it.finish() }, NavButton("") {} ) { - Download(c) + WizardDownloader(c.vm) }, WizardPage("flash", NavButton("") {}, NavButton("") {} @@ -74,60 +92,12 @@ class CreatePartFlow(private val desiredStartSector: Long): WizardFlow() { } } - -private class ProgressResponseBody( - private val responseBody: ResponseBody, - private val progressListener: ProgressListener -) : - ResponseBody() { - private var bufferedSource: BufferedSource? = null - override fun contentType(): MediaType? { - return responseBody.contentType() - } - - override fun contentLength(): Long { - return responseBody.contentLength() - } - - override fun source(): BufferedSource { - if (bufferedSource == null) { - bufferedSource = source(responseBody.source()).buffer() - } - return bufferedSource!! - } - - private fun source(source: Source): Source { - return object : ForwardingSource(source) { - var totalBytesRead = 0L - - @Throws(IOException::class) - override fun read(sink: Buffer, byteCount: Long): Long { - val bytesRead = super.read(sink, byteCount) - // read() returns the number of bytes read, or -1 if this source is exhausted. - totalBytesRead += if (bytesRead != -1L) bytesRead else 0 - progressListener.update( - totalBytesRead, - responseBody.contentLength(), - bytesRead == -1L - ) - return bytesRead - } - } - } -} - -internal interface ProgressListener { - fun update(bytesRead: Long, contentLength: Long, done: Boolean) -} - -private class CreatePartDataHolder(val vm: WizardActivityState, val desiredStartSector: Long): ProgressListener { +private class CreatePartDataHolder(val vm: WizardActivityState, val desiredStartSector: Long) { var meta by mutableStateOf(null) lateinit var p: SDUtils.Partition.FreeSpace var startSectorRelative = 0L var endSectorRelative = 0L var partitionName: String? = null - var scriptInet: String? = null - var scriptShaInet: String? = null var painter: @Composable (() -> Painter)? = null var rtype = "" @@ -148,24 +118,10 @@ private class CreatePartDataHolder(val vm: WizardActivityState, val desiredStart } } val parts = mutableStateListOf() - val inetAvailable = HashMap>() - val idNeeded = mutableStateListOf() val extraIdNeeded = mutableListOf() - val chosen = mutableStateMapOf() - val client by lazy { OkHttpClient().newBuilder().readTimeout(1L, TimeUnit.HOURS).addNetworkInterceptor { - val originalResponse: Response = it.proceed(it.request()) - return@addNetworkInterceptor originalResponse.newBuilder() - .body(ProgressResponseBody(originalResponse.body!!, this)) - .build() - }.build() } - var pl: ProgressListener? = null var romFolderName by mutableStateOf("") var romDisplayName by mutableStateOf("") - override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { - pl?.update(bytesRead, contentLength, done) - } - fun painterFromRtype(type: String): @Composable () -> Painter { val id = when (type) { "SFOS" -> R.drawable.ic_sailfish_os_logo @@ -374,9 +330,6 @@ private fun Shop(c: CreatePartDataHolder) { dmaMeta["name"] = o.getString("displayname") dmaMeta["creator"] = o.getString("creator") - c.scriptInet = o.getString("scriptname") - if (o.has("scriptSha256")) - c.scriptShaInet = o.getString("scriptSha256") dmaMeta["updateJson"] = o.getString("updateJson") rtype = o.getString("rtype") cmdline = @@ -395,7 +348,7 @@ private fun Shop(c: CreatePartDataHolder) { val id = extraIdNeeded.get(i) as String if (idUnneeded.contains(id)) throw IllegalStateException("id in both blockIdNeeded and extraIdNeeded") - idNeeded.add(id) + vm.idNeeded.add(id) this.extraIdNeeded.add(id) i++ } @@ -406,14 +359,14 @@ private fun Shop(c: CreatePartDataHolder) { val id = l.getString("id") if (parts.find { it.id == id } != null) throw IllegalStateException("duplicate $id in partitions?") - if (idNeeded.contains(id)) + if (vm.idNeeded.contains(id)) throw IllegalStateException("duplicate $id in idNeeded?") parts.add(Part(l.getLong("size"), l.getBoolean("isPercent"), l.getString("type"), id, l.getBoolean("needUnsparse"))) if (!idUnneeded.remove(id)) - idNeeded.add(id) + vm.idNeeded.add(id) i++ } if (idUnneeded.isNotEmpty()) @@ -422,10 +375,21 @@ private fun Shop(c: CreatePartDataHolder) { val inets = o.getJSONArray("inet") while (i < inets.length()) { val l = inets.getJSONObject(i) - inetAvailable[l.getString("id")] = - Pair(l.getString("url"), l.getString("desc")) + vm.inetAvailable[l.getString("id")] = + WizardActivityState.Downloadable( + l.getString("url"), + if (l.has("hash")) + l.getString("hash") else null, + l.getString("desc") + ) i++ } + vm.idNeeded.add("_install.sh_") + vm.inetAvailable["_install.sh_"] = WizardActivityState.Downloadable( + o.getString("scriptname"), if (o.has("scriptSha256")) + o.getString("scriptSha256") else null, + vm.activity.getString(R.string.installer_sh) + ) painter = painterFromRtype(rtype) } @@ -653,7 +617,7 @@ private fun Os(c: CreatePartDataHolder) { } Row(verticalAlignment = Alignment.CenterVertically) { Button(onClick = { c.parts.add( - CreatePartDataHolder.Part( + Part( 100L, true, "8305", @@ -686,171 +650,6 @@ private fun Os(c: CreatePartDataHolder) { } } -private class DledFile(val safFile: Uri?, val netFile: File?) { - fun delete() { - netFile?.delete() - } - - fun openInputStream(vm: WizardActivityState): InputStream { - netFile?.let { - return FileInputStream(it) - } - safFile?.let { - val istr = vm.activity.contentResolver.openInputStream(it) - if (istr != null) { - return istr - } - } - throw IllegalStateException("invalid DledFile OR failure") - } - - fun toFile(vm: WizardActivityState): File { - netFile?.let { return it } - safFile?.let { - val istr = vm.activity.contentResolver.openInputStream(it) - if (istr != null) { - val f = File(vm.logic.cacheDir, System.currentTimeMillis().toString()) - vm.copyUnpriv(istr, f) - istr.close() - return f - } - } - throw IllegalStateException("invalid DledFile OR safFile failure") - } -} - -@Composable -private fun Download(c: CreatePartDataHolder) { - Column(Modifier.fillMaxSize()) { - Card { - Row( - Modifier - .fillMaxWidth() - .padding(20.dp) - ) { - Icon(painterResource(id = R.drawable.ic_about), stringResource(id = R.string.icon_content_desc)) - Text(stringResource(id = R.string.provide_images)) - } - } - var cancelDownload by remember { mutableStateOf<(() -> Unit)?>(null) } - var progressText by remember { mutableStateOf(c.vm.activity.getString(R.string.connecting_text)) } - if (cancelDownload != null) { - AlertDialog( - onDismissRequest = {}, - confirmButton = { - Button(onClick = { cancelDownload!!() }) { - Text(stringResource(id = R.string.cancel)) - } - }, - title = { Text(stringResource(R.string.downloading)) }, - text = { - LoadingCircle(progressText, paddingBetween = 10.dp) - }) - } - for (i in (c.idNeeded + listOf("_install.sh_"))) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Column { - Text(i) - Text( - c.inetAvailable[i]?.second ?: stringResource( - if (i == "_install.sh_") R.string.installer_sh else R.string.user_selected), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Column { - if (c.chosen.containsKey(i)) { - Button(onClick = { - c.chosen[i]!!.delete() - c.chosen.remove(i) - }) { - Text(stringResource(R.string.undo)) - } - } else { - if (i == "_install.sh_" || c.inetAvailable.containsKey(i)) { - Button(onClick = { - CoroutineScope(Dispatchers.IO).launch { - try { - val downloadedFile = File(c.vm.logic.cacheDir, i) - val request = - Request.Builder().url(if (i == "_install.sh_") - c.scriptInet!! else c.inetAvailable[i]!!.first).build() - val call = c.client.newCall(request) - progressText = c.vm.activity.getString(R.string.connecting_text) - c.pl = object : ProgressListener { - override fun update( - bytesRead: Long, - contentLength: Long, - done: Boolean - ) { - progressText = c.vm.activity.getString(R.string.download_progress, - SOUtils.humanReadableByteCountBin(bytesRead), SOUtils.humanReadableByteCountBin(contentLength)) - } - } - cancelDownload = { - call.cancel() - downloadedFile.delete() - cancelDownload = null - } - val response = call.execute() - val desiredHash = if (i == "_install.sh_") c.scriptShaInet!! else null - - val rawSink = downloadedFile.sink() - val sink = if (desiredHash != null) HashingSink.sha256(rawSink) else rawSink - val buffer = sink.buffer() - buffer.writeAll(response.body!!.source()) - buffer.close() - val realHash = if (desiredHash != null) - (sink as HashingSink).hash.hex() else null - if (!call.isCanceled()) { - if (desiredHash != null && realHash != desiredHash) - throw IllegalStateException("hash $realHash does not match expected hash $desiredHash") - c.chosen[i] = DledFile(null, downloadedFile) - } - } catch (e: Exception) { - Log.e("ABM", Log.getStackTraceString(e)) - withContext(Dispatchers.Main) { - Toast.makeText( - c.vm.activity, - c.vm.activity.getString(R.string.dl_error), - Toast.LENGTH_LONG - ).show() - } - } - c.pl = null - cancelDownload = null - } - }) { - Text(stringResource(R.string.download)) - } - } - Button(onClick = { - c.vm.activity.chooseFile("*/*") { - c.chosen[i] = DledFile(it, null) - } - }) { - Text(stringResource(R.string.choose)) - } - } - } - } - } - val isOk = c.idNeeded.find { !c.chosen.containsKey(it) } == null && c.chosen.containsKey("_install.sh_") - LaunchedEffect(isOk) { - if (isOk) { - c.vm.onNext = { it.navigate("flash") } - c.vm.nextText = c.vm.activity.getString(R.string.install) - } else { - c.vm.onNext = {} - c.vm.nextText = "" - } - } - } -} - @Composable private fun Flash(c: CreatePartDataHolder) { val vm = c.vm @@ -861,7 +660,7 @@ private fun Flash(c: CreatePartDataHolder) { val fn = c.romFolderName terminal.add(vm.activity.getString(R.string.term_f_name, fn)) terminal.add(vm.activity.getString(R.string.term_g_name, c.romDisplayName)) - val tmpFile = c.chosen["_install.sh_"]!!.toFile(vm) + val tmpFile = c.vm.chosen["_install.sh_"]!!.toFile(vm) tmpFile.setExecutable(true) terminal.add(vm.activity.getString(R.string.term_creating_pt)) @@ -928,9 +727,9 @@ private fun Flash(c: CreatePartDataHolder) { terminal.add(vm.activity.getString(R.string.term_flashing_imgs)) for (part in c.parts) { - if (!c.idNeeded.contains(part.id)) continue + if (!c.vm.idNeeded.contains(part.id)) continue terminal.add(vm.activity.getString(R.string.term_flashing_s, part.id)) - val f = c.chosen[part.id]!! + val f = c.vm.chosen[part.id]!! val tp = File(meta.dumpKernelPartition(createdParts[part]!!).path) if (part.sparse) { val f2 = f.toFile(c.vm) @@ -955,7 +754,7 @@ private fun Flash(c: CreatePartDataHolder) { terminal.add(vm.activity.getString(R.string.term_patching_os)) var cmd = "FORMATDATA=true " + tmpFile.absolutePath + " $fn" for (i in c.extraIdNeeded) { - cmd += " " + c.chosen[i]!!.toFile(vm).absolutePath + cmd += " " + c.vm.chosen[i]!!.toFile(vm).absolutePath } for (i in c.parts) { cmd += " " + createdParts[i] diff --git a/app/src/main/java/org/andbootmgr/app/DroidBootFlow.kt b/app/src/main/java/org/andbootmgr/app/DroidBootFlow.kt index 15700ba3..648f62b5 100644 --- a/app/src/main/java/org/andbootmgr/app/DroidBootFlow.kt +++ b/app/src/main/java/org/andbootmgr/app/DroidBootFlow.kt @@ -44,8 +44,9 @@ import java.io.File import java.io.IOException import java.net.URL -class DroidBootFlow() : WizardFlow() { +class DroidBootFlow : WizardFlow() { override fun get(vm: WizardActivityState): List { + val booted = vm.deviceInfo.isBooted(vm.logic) return listOf(WizardPage("start", NavButton(vm.activity.getString(R.string.cancel)) { it.finish() }, NavButton(vm.activity.getString(R.string.next)) { it.navigate("input") }) @@ -53,20 +54,15 @@ class DroidBootFlow() : WizardFlow() { Start(vm) }, WizardPage("input", NavButton(vm.activity.getString(R.string.prev)) { it.navigate("start") }, - NavButton(vm.activity.getString(R.string.next)) { it.navigate(if (vm.deviceInfo.postInstallScript) "shSel" else - (if (!vm.deviceInfo.isBooted(vm.logic)) "select" else "flash")) } + NavButton(vm.activity.getString(R.string.next)) { it.navigate(if (vm.deviceInfo.postInstallScript || !booted) + "dload" else "flash") } ) { Input(vm) - }, WizardPage("shSel", - NavButton(vm.activity.getString(R.string.prev)) { it.navigate("input") }, - NavButton("") {} - ) { - SelectInstallSh(vm) - }, WizardPage("select", - NavButton(vm.activity.getString(R.string.prev)) { it.navigate(if (vm.deviceInfo.postInstallScript) "shSel" else "input") }, + }, WizardPage("dload", + NavButton(vm.activity.getString(R.string.cancel)) { it.finish() }, NavButton("") {} ) { - SelectDroidBoot(vm) + WizardDownloader(vm) }, WizardPage("flash", NavButton("") {}, NavButton("") {} @@ -98,19 +94,61 @@ private fun Start(vm: WizardActivityState) { @Composable private fun Input(vm: WizardActivityState) { + var loading by remember { mutableStateOf(!vm.deviceInfo.isBooted(vm.logic) || vm.deviceInfo.postInstallScript) } + LaunchedEffect(Unit) { + if (!loading) return@LaunchedEffect + CoroutineScope(Dispatchers.IO).launch { + try { + val jsonText = + URL("https://raw.githubusercontent.com/Android-Boot-Manager/ABM-json/master/devices/" + vm.codename + ".json").readText() + val json = JSONTokener(jsonText).nextValue() as JSONObject + if (BuildConfig.VERSION_CODE < json.getInt("minAppVersion")) + throw IllegalStateException("please upgrade app") + if (!vm.deviceInfo.isBooted(vm.logic)) { + val bl = json.getJSONObject("bootloader") + val url = bl.getString("url") + val sha = if (bl.has("sha256")) bl.getString("sha256") else null + vm.inetAvailable["droidboot"] = WizardActivityState.Downloadable( + url, sha, vm.activity.getString(R.string.droidboot_online) + ) + vm.idNeeded.add("droidboot") + } + if (vm.deviceInfo.postInstallScript) { + val i = json.getJSONObject("installScript") + val url = i.getString("url") + val sha = if (i.has("sha256")) i.getString("sha256") else null + vm.inetAvailable["install"] = WizardActivityState.Downloadable( + url, sha, vm.activity.getString(R.string.installer_sh) + ) + vm.idNeeded.add("install") + } + loading = false + } catch (e: Exception) { + Handler(Looper.getMainLooper()).post { + Toast.makeText(vm.activity, R.string.dl_error, Toast.LENGTH_LONG).show() + } + Log.e("ABM droidboot json", Log.getStackTraceString(e)) + } + } + } + if (loading) { + LoadingCircle(stringResource(R.string.loading), modifier = Modifier.fillMaxSize()) + return + } Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxSize() ) { - var text by remember { mutableStateOf(vm.activity.getString(R.string.android)) } - LaunchedEffect(text) { vm.texts["OsName"] = text.trim() } - val e = text.isBlank() || !text.matches(Regex("[\\dA-Za-z]+")) + LaunchedEffect(Unit) { // TODO can't I do this better? + if (vm.texts.isBlank()) + vm.texts = vm.activity.getString(R.string.android) + } + val e = vm.texts.isBlank() || !vm.texts.matches(Regex("[\\dA-Za-z]+")) Text(stringResource(R.string.enter_name_for_current), textAlign = TextAlign.Center, modifier = Modifier.padding(vertical = 5.dp)) TextField( - value = text, + value = vm.texts, onValueChange = { - text = it - vm.texts["OsName"] = it.trim() + vm.texts = it }, label = { Text(stringResource(R.string.os_name)) }, isError = e @@ -149,7 +187,7 @@ fun SelectDroidBoot(vm: WizardActivityState) { if (nextButtonAvailable) { Text(stringResource(id = R.string.successfully_selected)) } else { - Text(stringResource(R.string.choose_droidboot_online)) + //Text(stringResource(R.string.choose_droidboot_online)) Button(onClick = { vm.activity.chooseFile("*/*") { vm.flashes["DroidBootFlashType"] = Pair(it, null) @@ -162,7 +200,7 @@ fun SelectDroidBoot(vm: WizardActivityState) { } val ctx = LocalContext.current Button(onClick = { - CoroutineScope(Dispatchers.Default).launch { + CoroutineScope(Dispatchers.IO).launch { try { val jsonText = URL("https://raw.githubusercontent.com/Android-Boot-Manager/ABM-json/master/devices/" + vm.codename + ".json").readText() @@ -202,7 +240,7 @@ fun SelectInstallSh(vm: WizardActivityState, update: Boolean = false) { if (nextButtonAvailable) { Text(stringResource(id = R.string.successfully_selected)) } else { - Text(stringResource(R.string.choose_install_s_online)) + //Text(stringResource(R.string.choose_install_s_online)) Button(onClick = { vm.activity.chooseFile("*/*") { vm.flashes[flashType] = Pair(it, null) @@ -290,11 +328,13 @@ private fun Flash(vm: WizardActivityState) { } val r = vm.logic.create(meta.s[0] as SDUtils.Partition.FreeSpace, 0, - (meta.sectors - 2048) / 41 + 2048, + ((meta.sectors - 2048) / 41 + 2048) // create meta partition proportional to sd size + .coerceAtLeast((512L * 1024L * 1024L) / meta.logicalSectorSizeBytes) // but never less than 512mb + .coerceAtMost((4L * 1024L * 1024L * 1024L) / meta.logicalSectorSizeBytes), // and never more than 4gb "8301", "abm_settings" ).to(terminal).exec() - if (r.out.joinToString("\n").contains("old")) { + if (r.out.joinToString("\n").contains("kpartx")) { terminal.add(vm.activity.getString(R.string.term_reboot_asap)) } if (r.isSuccess) { @@ -343,7 +383,7 @@ private fun Flash(vm: WizardActivityState) { db["timeout"] = "5" db.exportToFile(File(vm.logic.abmDb, "db.conf")) val entry = ConfigFile() - entry["title"] = vm.texts["OsName"]!! + entry["title"] = vm.texts.trim() entry["linux"] = "null" entry["initrd"] = "null" entry["dtb"] = "null" diff --git a/app/src/main/java/org/andbootmgr/app/WizardActivity.kt b/app/src/main/java/org/andbootmgr/app/WizardActivity.kt index 692948f2..791f6977 100644 --- a/app/src/main/java/org/andbootmgr/app/WizardActivity.kt +++ b/app/src/main/java/org/andbootmgr/app/WizardActivity.kt @@ -1,28 +1,50 @@ package org.andbootmgr.app import android.net.Uri +import android.util.Log import android.view.WindowManager +import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.core.net.toFile import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.topjohnwu.superuser.io.SuFileOutputStream +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.andbootmgr.app.util.AbmOkHttp +import org.andbootmgr.app.util.SOUtils import java.io.File import java.io.FileInputStream import java.io.IOException @@ -98,8 +120,13 @@ class WizardActivityState(val mvm: MainActivityState) { var onPrev by mutableStateOf<((WizardActivityState) -> Unit)?>(null) var onNext by mutableStateOf<((WizardActivityState) -> Unit)?>(null) + // TODO remove below two var flashes: HashMap> = HashMap() - var texts: HashMap = HashMap() + var texts by mutableStateOf("") + val inetAvailable = HashMap() + val idNeeded = mutableStateListOf() + val chosen = mutableStateMapOf() + class Downloadable(val url: String, val hash: String?, val desc: String) fun navigate(next: String) { prevText = null @@ -160,6 +187,40 @@ class WizardActivityState(val mvm: MainActivityState) { } } +class DledFile(val safFile: Uri?, val netFile: File?) { + fun delete() { + netFile?.delete() + } + + fun openInputStream(vm: WizardActivityState): InputStream { + netFile?.let { + return FileInputStream(it) + } + safFile?.let { + val istr = vm.activity.contentResolver.openInputStream(it) + if (istr != null) { + return istr + } + } + throw IllegalStateException("invalid DledFile OR failure") + } + + fun toFile(vm: WizardActivityState): File { + netFile?.let { return it } + safFile?.let { + val istr = vm.activity.contentResolver.openInputStream(it) + if (istr != null) { + val f = File(vm.logic.cacheDir, System.currentTimeMillis().toString()) + vm.copyUnpriv(istr, f) + istr.close() + return f + } + } + throw IllegalStateException("invalid DledFile OR safFile failure") + } +} + + class NavButton(val text: String, val onClick: (WizardActivityState) -> Unit) class WizardPage(override val name: String, override val prev: NavButton, override val next: NavButton, override val run: @Composable () -> Unit @@ -187,4 +248,113 @@ fun BasicButtonRow(prev: String, onPrev: () -> Unit, Text(next) } } +} + +@Composable +fun WizardDownloader(vm: WizardActivityState) { + Column(Modifier.fillMaxSize()) { + Card { + Row( + Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + Icon(painterResource(id = R.drawable.ic_about), stringResource(id = R.string.icon_content_desc)) + Text(stringResource(id = R.string.provide_images)) + } + } + var cancelDownload by remember { mutableStateOf<(() -> Unit)?>(null) } + var progressText by remember { mutableStateOf(vm.activity.getString(R.string.connecting_text)) } + if (cancelDownload != null) { + AlertDialog( + onDismissRequest = {}, + confirmButton = { + Button(onClick = { cancelDownload!!() }) { + Text(stringResource(id = R.string.cancel)) + } + }, + title = { Text(stringResource(R.string.downloading)) }, + text = { + LoadingCircle(progressText, paddingBetween = 10.dp) + }) + } + for (i in vm.idNeeded) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Column { + Text(i) + Text( + vm.inetAvailable[i]?.desc ?: stringResource(R.string.user_selected), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Column { + if (vm.chosen.containsKey(i)) { + Button(onClick = { + vm.chosen[i]!!.delete() + vm.chosen.remove(i) + }) { + Text(stringResource(R.string.undo)) + } + } else { + if (vm.inetAvailable.containsKey(i)) { + Button(onClick = { + CoroutineScope(Dispatchers.Main).launch { + val url = vm.inetAvailable[i]!!.url + val downloadedFile = File(vm.logic.cacheDir, i) + val h = vm.inetAvailable[i]!!.hash + val client = AbmOkHttp(url, downloadedFile, h) { bytesRead, contentLength, _ -> + progressText = vm.activity.getString(R.string.download_progress, + SOUtils.humanReadableByteCountBin(bytesRead), SOUtils.humanReadableByteCountBin(contentLength)) + } + try { + progressText = vm.activity.getString(R.string.connecting_text) + cancelDownload = { + client.cancel() + cancelDownload = null + } + if (client.run()) { + vm.chosen[i] = DledFile(null, downloadedFile) + } + } catch (e: Exception) { + Log.e("ABM", Log.getStackTraceString(e)) + withContext(Dispatchers.Main) { + Toast.makeText( + vm.activity, + vm.activity.getString(R.string.dl_error), + Toast.LENGTH_LONG + ).show() + } + } + cancelDownload = null + } + }) { + Text(stringResource(R.string.download)) + } + } + Button(onClick = { + vm.activity.chooseFile("*/*") { + vm.chosen[i] = DledFile(it, null) + } + }) { + Text(stringResource(R.string.choose)) + } + } + } + } + } + val isOk = vm.idNeeded.find { !vm.chosen.containsKey(it) } == null + LaunchedEffect(isOk) { + if (isOk) { + vm.onNext = { it.navigate("flash") } + vm.nextText = vm.activity.getString(R.string.install) + } else { + vm.onNext = {} + vm.nextText = "" + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/andbootmgr/app/util/AbmOkHttp.kt b/app/src/main/java/org/andbootmgr/app/util/AbmOkHttp.kt new file mode 100644 index 00000000..dba0ebb6 --- /dev/null +++ b/app/src/main/java/org/andbootmgr/app/util/AbmOkHttp.kt @@ -0,0 +1,109 @@ +package org.andbootmgr.app.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.MediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import okio.Buffer +import okio.BufferedSource +import okio.ForwardingSource +import okio.HashingSink +import okio.Source +import okio.buffer +import okio.sink +import org.andbootmgr.app.HashMismatchException +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit + +class AbmOkHttp(private val url: String, private val f: File, private val hash: String?, + pl: ((bytesRead: Long, contentLength: Long, done: Boolean) -> Unit)?) { + private val client by lazy { OkHttpClient().newBuilder().readTimeout(1L, TimeUnit.HOURS) + .let { c -> + if (pl != null) + c.addNetworkInterceptor { + val originalResponse: Response = it.proceed(it.request()) + return@addNetworkInterceptor originalResponse.newBuilder() + .body(ProgressResponseBody(originalResponse.body!!, pl)) + .build() + } + else c + }.build() } + private var call: Call? = null + + suspend fun run(): Boolean { + return withContext(Dispatchers.IO) { + val request = + Request.Builder().url(url).build() + val call = client.newCall(request) + this@AbmOkHttp.call = call + val response = call.execute() + val rawSink = f.sink() + val sink = if (hash != null) HashingSink.sha256(rawSink) else rawSink + val buffer = sink.buffer() + buffer.writeAll(response.body!!.source()) + buffer.close() + val realHash = if (hash != null) (sink as HashingSink).hash.hex() else null + if (!call.isCanceled()) { + if (hash != null && realHash != hash) { + try { + f.delete() + } catch (_: Exception) {} + throw HashMismatchException("hash $realHash does not match expected hash $hash") + } + return@withContext true + } + return@withContext false + } + } + + fun cancel() { + call?.cancel() + f.delete() + } + + private class ProgressResponseBody( + private val responseBody: ResponseBody, + private val progressListener: (bytesRead: Long, contentLength: Long, done: Boolean) -> Unit + ) : + ResponseBody() { + private var bufferedSource: BufferedSource? = null + override fun contentType(): MediaType? { + return responseBody.contentType() + } + + override fun contentLength(): Long { + return responseBody.contentLength() + } + + override fun source(): BufferedSource { + if (bufferedSource == null) { + bufferedSource = source(responseBody.source()).buffer() + } + return bufferedSource!! + } + + private fun source(source: Source): Source { + return object : ForwardingSource(source) { + var totalBytesRead = 0L + + @Throws(IOException::class) + override fun read(sink: Buffer, byteCount: Long): Long { + val bytesRead = super.read(sink, byteCount) + // read() returns the number of bytes read, or -1 if this source is exhausted. + totalBytesRead += if (bytesRead != -1L) bytesRead else 0 + progressListener( + totalBytesRead, + responseBody.contentLength(), + bytesRead == -1L + ) + return bytesRead + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 92604798..70512e60 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -226,5 +226,4 @@ -- GUI-Name: %s -- Bessere Betriebssystem aus… Liste der ROMs aus dem Open ROM Projekt konnte nicht geladen werden - Bitte wähle ein DroidBoot-Image, oder nutze den Download-Knopf, um die aktuellste Version automatisch aus dem Internet herunterzuladen (für die meisten Nutzer empfohlen). \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c8a66635..9e8fa987 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -70,8 +70,7 @@ OS name Invalid input DroidBoot Icon - Please choose a DroidBoot image, or use the download button to automatically download the latest version from the internet (recommended for most users). - Please choose an installer shell script, or use the download button to automatically download the latest version from the internet (recommended for most users). + DroidBoot bootloader image This will reinstall DroidBoot File not available, please try again later! Local update