commit 2047f32440e1f384f4a7efc61b88757747111d9e parent 1a488425b2e11163acc606ca1c054a7834749ad6 Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Mon, 30 Oct 2023 13:01:47 +0100 feat(manager): LSPatch tab Diffstat:
14 files changed, 632 insertions(+), 426 deletions(-)
diff --git a/manager/proguard-rules.pro b/manager/proguard-rules.pro @@ -1,2 +1,3 @@ -dontwarn com.google.errorprone.annotations.** --dontwarn com.google.auto.value.**- \ No newline at end of file +-dontwarn com.google.auto.value.** +-keep enum me.rhunk.snapenhance.manager.** { *; }+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/APKMirror.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/APKMirror.kt @@ -1,17 +1,24 @@ package me.rhunk.snapenhance.manager.data +import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize import okhttp3.OkHttpClient import okhttp3.Request import org.jsoup.Jsoup import kotlin.math.absoluteValue +@Parcelize data class DownloadItem( val title: String, val releaseDate: String, val downloadPage: String -) { +): Parcelable { + @IgnoredOnParcel val shortTitle = title.substringBefore("(").trim() + @IgnoredOnParcel val hash = (title + releaseDate + downloadPage).hashCode().absoluteValue.toString(16) + @IgnoredOnParcel val isBeta = title.contains("Beta", ignoreCase = true) } diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt @@ -7,9 +7,15 @@ class SharedConfig( ) { private val sharedPreferences = context.getSharedPreferences("snapenhance", Context.MODE_PRIVATE) - var snapchatPackageName get() = sharedPreferences.getString("snapchatPackageName", "com.snapchat.android")?.takeIf { it.isNotEmpty() } + val apkCache by lazy { + context.cacheDir.resolve("snapchat_apk_cache").also { + if (!it.exists()) it.mkdirs() + } + } + + var snapchatPackageName get() = sharedPreferences.getString("snapchatPackageName", "com.snapchat.android")?.takeIf { it.isNotEmpty() } ?: "com.snapchat.android" set(value) = sharedPreferences.edit().putString("snapchatPackageName", value).apply() - var snapEnhancePackageName get() = sharedPreferences.getString("snapEnhancePackageName", "me.rhunk.snapenhance")?.takeIf { it.isNotEmpty() } + var snapEnhancePackageName get() = sharedPreferences.getString("snapEnhancePackageName", "me.rhunk.snapenhance")?.takeIf { it.isNotEmpty() } ?: "me.rhunk.snapenhance" set(value) = sharedPreferences.edit().putString("snapEnhancePackageName", value).apply() } \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatch.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatch.kt @@ -137,8 +137,9 @@ class LSPatch( } //embed modules - printLog("embedding modules") + printLog("Embedding modules") modules.forEach { (packageName, module) -> + printLog("- $packageName") dstZFile.add("assets/lspatch/modules/$packageName.apk", module.inputStream()) } @@ -162,17 +163,17 @@ class LSPatch( printLog("Done") } - fun patchSplits(inputs: List<File>): List<File> { - val outputs = mutableListOf<File>() + fun patchSplits(inputs: List<File>): Map<String, File> { + val outputs = mutableMapOf<String, File>() inputs.forEach { input -> - val outputFile = File.createTempFile("patched", ".apk", context.cacheDir) + val outputFile = File.createTempFile("patched", ".apk", context.externalCacheDir ?: context.cacheDir) if (input.name.contains("split")) { resignApk(input, outputFile) - outputs.add(outputFile) + outputs[input.name] = outputFile return@forEach } patch(input, outputFile) - outputs.add(outputFile) + outputs["base.apk"] = outputFile } return outputs } @@ -206,8 +207,6 @@ class LSPatch( outputFile.delete() printLog("Failed to patch") printLog(it) - }.onSuccess { - outputFile.delete() } } } \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/MainActivity.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/MainActivity.kt @@ -12,10 +12,11 @@ import androidx.navigation.compose.rememberNavController import me.rhunk.snapenhance.manager.data.SharedConfig import me.rhunk.snapenhance.manager.ui.tab.HomeTab import me.rhunk.snapenhance.manager.ui.tab.SettingsTab +import me.rhunk.snapenhance.manager.ui.tab.download.InstallPackageTab class MainActivity : ComponentActivity() { companion object{ - private val primaryTabs = listOf(HomeTab::class, SettingsTab::class) + private val primaryTabs = listOf(HomeTab::class, SettingsTab::class, InstallPackageTab::class) } override fun onCreate(savedInstanceState: Bundle?) { diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/Navigation.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/Navigation.kt @@ -50,25 +50,22 @@ class Navigation( tabs.firstOrNull { it.route == navBackStackEntry?.destination?.route }?.FloatingActionButtons() } - fun navigateTo(tab: KClass<out Tab>) { - navHostController.navigate(tabs.first { it::class == tab }.route) - } - - fun navigateTo(tab: KClass<out Tab>, noHistory: Boolean) { + fun navigateTo(tab: KClass<out Tab>, noHistory: Boolean = false) { navHostController.navigate(tabs.first { it::class == tab }.route) { - restoreState = false - launchSingleTop = true - popUpTo(navHostController.graph.findStartDestination().id) { - saveState = true + if (noHistory) { + restoreState = false + launchSingleTop = true + popUpTo(navHostController.graph.findStartDestination().id) { + saveState = true + } } } } - fun navigateTo(tab: KClass<out Tab>, args: Bundle) { - navHostController.currentBackStackEntry?.savedStateHandle?.apply { - set("args", args) - } - navigateTo(tab) + + fun navigateTo(tab: KClass<out Tab>, args: Bundle, noHistory: Boolean = false) { + navigateTo(tab, noHistory) + navHostController.currentBackStackEntry?.savedStateHandle?.set("args", args) } @Composable diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/Tab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/Tab.kt @@ -27,7 +27,7 @@ open class Tab( lateinit var navigation: Navigation lateinit var sharedConfig: SharedConfig - fun getArguments() = navigation.navHostController.previousBackStackEntry?.savedStateHandle?.get<Bundle>("args") + fun getArguments() = navigation.navHostController.currentBackStackEntry?.savedStateHandle?.get<Bundle>("args") open fun init(activity: ComponentActivity) { this.activity = activity diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/components/Dialogs.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/components/Dialogs.kt @@ -9,8 +9,10 @@ import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp @Composable @@ -34,4 +36,34 @@ fun ConfirmationDialog(title: String, onDismiss: () -> Unit, onConfirm: () -> Un } } } +} + + +@Composable +fun DowngradeNoticeDialog(onDismiss: () -> Unit, onSuccess: () -> Unit) { + Card { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(text = "Downgrade Notice", fontSize = 24.sp) + Text(text = "You are about to update the app. If you're installing an older version over a newer one, make sure you have CorePatch installed. Otherwise, you will need to uninstall and install.", fontSize = 12.sp) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceAround + ) { + Button(onClick = onDismiss) { + Text(text = "Cancel") + } + Button(onClick = onSuccess) { + Text(text = "Continue") + } + } + } } \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/HomeTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/HomeTab.kt @@ -15,9 +15,9 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight @@ -25,7 +25,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import me.rhunk.snapenhance.manager.BuildConfig import me.rhunk.snapenhance.manager.lspatch.config.Constants import me.rhunk.snapenhance.manager.ui.Tab import me.rhunk.snapenhance.manager.ui.tab.download.SEDownloadTab @@ -46,6 +45,7 @@ class HomeTab : Tab("home", true, icon = Icons.Default.Home) { var snapEnhanceInfo by remember { mutableStateOf(null as PackageInfo?) } Column { + Card( modifier = Modifier .fillMaxWidth() @@ -54,37 +54,33 @@ class HomeTab : Tab("home", true, icon = Icons.Default.Home) { Row( modifier = Modifier .fillMaxWidth() + .clickable { + navigation.navigateTo(SEDownloadTab::class) + } .padding(16.dp), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically ) { Column { - Text(text = "Snapchat", fontSize = 24.sp, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold) - snapchatAppInfo?.let { - Text(text = "${it.versionName} (${it.longVersionCode})", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(text = "SnapEnhance", fontSize = 24.sp, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold) + snapEnhanceInfo?.let { + Text(text = "${it.versionName} (${it.longVersionCode}) - ${if ((it.applicationInfo.flags and FLAG_DEBUGGABLE) != 0) "Debug" else "Release"}", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } } Row( modifier = Modifier .weight(1f), horizontalArrangement = Arrangement.End, - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically ) { - snapchatAppInfo?.let { appInfo -> - val isLSPatched = appInfo.applicationInfo.appComponentFactory == Constants.PROXY_APP_COMPONENT_FACTORY - if (isLSPatched) { - Text(text = "Patched", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) - } - OutlinedButton(onClick = { - navigation.navigateTo(SnapchatPatchTab::class) - }) { - Text(text = if (isLSPatched) "Repatch" else "Patch") - } + snapEnhanceInfo?.let { + Text(text = "Installed", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) } ?: run { Text(text = "Not installed", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) } + + Icon(imageVector = Icons.AutoMirrored.Default.OpenInNew, contentDescription = null, Modifier.padding(10.dp)) } } - } Card( @@ -96,25 +92,28 @@ class HomeTab : Tab("home", true, icon = Icons.Default.Home) { modifier = Modifier .fillMaxWidth() .clickable { - navigation.navigateTo(SEDownloadTab::class) + navigation.navigateTo(SnapchatPatchTab::class) } .padding(16.dp), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically ) { Column { - Text(text = "SnapEnhance", fontSize = 24.sp, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold) - snapEnhanceInfo?.let { - Text(text = "${it.versionName} (${it.longVersionCode}) - ${if ((it.applicationInfo.flags and FLAG_DEBUGGABLE) != 0) "Debug" else "Release"}", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(text = "Snapchat", fontSize = 24.sp, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold) + snapchatAppInfo?.let { + Text(text = "${it.versionName} (${it.longVersionCode})", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } } Row( modifier = Modifier .weight(1f), - horizontalArrangement = Arrangement.End, - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + horizontalArrangement = Arrangement.spacedBy(5.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically ) { - snapEnhanceInfo?.let { - Text(text = "Installed", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) + snapchatAppInfo?.let { appInfo -> + val isLSPatched = appInfo.applicationInfo.appComponentFactory == Constants.PROXY_APP_COMPONENT_FACTORY + if (isLSPatched) { + Text(text = "Patched", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) + } } ?: run { Text(text = "Not installed", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) } @@ -122,6 +121,7 @@ class HomeTab : Tab("home", true, icon = Icons.Default.Home) { Icon(imageVector = Icons.AutoMirrored.Default.OpenInNew, contentDescription = null, Modifier.padding(10.dp)) } } + } } @@ -129,10 +129,10 @@ class HomeTab : Tab("home", true, icon = Icons.Default.Home) { coroutineScope.launch(Dispatchers.IO) { runCatching { snapchatAppInfo = runCatching { - context.packageManager.getPackageInfo(sharedConfig.snapchatPackageName ?: "com.snapchat.android", 0) + context.packageManager.getPackageInfo(sharedConfig.snapchatPackageName, 0) }.getOrNull() snapEnhanceInfo = runCatching { - context.packageManager.getPackageInfo(sharedConfig.snapEnhancePackageName ?: BuildConfig.APPLICATION_ID, 0) + context.packageManager.getPackageInfo(sharedConfig.snapEnhancePackageName, 0) }.getOrNull() } } diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/InstallAppTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/InstallAppTab.kt @@ -1,176 +0,0 @@ -package me.rhunk.snapenhance.manager.ui.tab.download - -import android.content.Intent -import android.widget.Toast -import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -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.material3.CircularProgressIndicator -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.core.content.FileProvider -import androidx.core.net.toUri -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import me.rhunk.snapenhance.manager.data.download.InstallStage -import me.rhunk.snapenhance.manager.data.download.SEArtifact -import me.rhunk.snapenhance.manager.ui.Tab -import me.rhunk.snapenhance.manager.ui.tab.HomeTab -import okhttp3.OkHttpClient -import okhttp3.Request -import java.io.File - - -class InstallAppTab : Tab("install_app") { - private lateinit var installPackageIntentLauncher: ActivityResultLauncher<Intent> - private lateinit var uninstallPackageIntentLauncher: ActivityResultLauncher<Intent> - private var uninstallPackageCallback: ((resultCode: Int, data: Intent?) -> Unit)? = null - private var installPackageCallback: ((resultCode: Int, data: Intent?) -> Unit)? = null - - override fun init(activity: ComponentActivity) { - super.init(activity) - installPackageIntentLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - installPackageCallback?.invoke(it.resultCode, it.data) - } - uninstallPackageIntentLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - uninstallPackageCallback?.invoke(it.resultCode, it.data) - } - } - - - private fun downloadArtifact(artifact: SEArtifact, progress: (Int) -> Unit): File? { - val endpoint = Request.Builder().url(artifact.downloadUrl).build() - val response = OkHttpClient().newCall(endpoint).execute() - if (!response.isSuccessful) throw Throwable("Failed to download artifact: ${response.code}") - - return response.body.byteStream().use { input -> - val file = activity.externalCacheDirs.first().resolve(artifact.fileName) - runCatching { - file.outputStream().use { output -> - val buffer = ByteArray(4 * 1024) - var read: Int - var totalRead = 0L - val totalSize = response.body.contentLength() - while (input.read(buffer).also { read = it } != -1) { - output.write(buffer, 0, read) - totalRead += read - progress((totalRead * 100 / totalSize).toInt()) - } - } - file - }.getOrNull() - } - } - - - @Composable - @Suppress("DEPRECATION") - override fun Content() { - val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current - var installStage by remember { mutableStateOf(InstallStage.DOWNLOADING) } - var downloadProgress by remember { mutableIntStateOf(0) } - var downloadedFile by remember { mutableStateOf<File?>(null) } - - LaunchedEffect(Unit) { - uninstallPackageCallback = null - installPackageCallback = null - } - - val artifact = getArguments()?.getParcelable<SEArtifact>("artifact") ?: return - val appPackage = getArguments()?.getString("appPackage") ?: return - val shouldUninstall = getArguments()?.getBoolean("uninstall") ?: false - - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - if (installStage != InstallStage.DONE && installStage != InstallStage.ERROR) { - CircularProgressIndicator() - } - } - - when (installStage) { - InstallStage.DOWNLOADING -> { - Text(text = "Downloading ${artifact.fileName}... ($downloadProgress%)") - } - InstallStage.UNINSTALLING -> { - Text(text = "Uninstalling app $appPackage...") - } - InstallStage.INSTALLING -> { - Text(text = "Installing ${artifact.fileName}...") - } - InstallStage.DONE -> { - LaunchedEffect(Unit) { - navigation.navigateTo(HomeTab::class, noHistory = true) - Toast.makeText(context, "${artifact.fileName} installed successfully!", Toast.LENGTH_SHORT).show() - } - } - InstallStage.ERROR -> Text(text = "Failed to install ${artifact.fileName}. Check logcat for more details.") - } - } - - fun installPackage() { - installStage = InstallStage.INSTALLING - installPackageCallback = resultCallbacks@{ code, _ -> - installStage = if (code != ComponentActivity.RESULT_OK) { - InstallStage.ERROR - } else { - InstallStage.DONE - } - downloadedFile?.delete() - } - - installPackageIntentLauncher.launch(Intent(Intent.ACTION_INSTALL_PACKAGE).apply { - println(context) - data = FileProvider.getUriForFile(context, "me.rhunk.snapenhance.manager.provider", downloadedFile!!) - setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - putExtra(Intent.EXTRA_RETURN_RESULT, true) - }) - } - - - LaunchedEffect(Unit) { - coroutineScope.launch(Dispatchers.IO) { - downloadedFile = downloadArtifact(artifact) { - downloadProgress = it - } ?: run { - installStage = InstallStage.ERROR - return@launch - } - if (shouldUninstall) { - installStage = InstallStage.UNINSTALLING - val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE).apply { - data = "package:$appPackage".toUri() - putExtra(Intent.EXTRA_RETURN_RESULT, true) - } - uninstallPackageCallback = resultCallback@{ resultCode, _ -> - if (resultCode != ComponentActivity.RESULT_OK) { - installStage = InstallStage.ERROR - downloadedFile?.delete() - return@resultCallback - } - installPackage() - } - uninstallPackageIntentLauncher.launch(intent) - } else { - installPackage() - } - } - } - } -} diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/InstallPackageTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/InstallPackageTab.kt @@ -0,0 +1,190 @@ +package me.rhunk.snapenhance.manager.ui.tab.download + +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.manager.data.download.InstallStage +import me.rhunk.snapenhance.manager.ui.Tab +import me.rhunk.snapenhance.manager.ui.tab.HomeTab +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File + + +class InstallPackageTab : Tab("install_app") { + private lateinit var installPackageIntentLauncher: ActivityResultLauncher<Intent> + private lateinit var uninstallPackageIntentLauncher: ActivityResultLauncher<Intent> + private var uninstallPackageCallback: ((resultCode: Int, data: Intent?) -> Unit)? = null + private var installPackageCallback: ((resultCode: Int, data: Intent?) -> Unit)? = null + + override fun init(activity: ComponentActivity) { + super.init(activity) + installPackageIntentLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + installPackageCallback?.invoke(it.resultCode, it.data) + } + uninstallPackageIntentLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + uninstallPackageCallback?.invoke(it.resultCode, it.data) + } + } + + private fun downloadArtifact(url: String, progress: (Float) -> Unit): File? { + val urlScheme = Uri.parse(url).scheme + if (urlScheme != "https" && urlScheme != "http") { + val file = File(url) + val dest = File(activity.externalCacheDirs.first(), file.name).also { + it.deleteOnExit() + } + if (dest.exists()) return file + file.copyTo(dest) + return dest + } + + val endpoint = Request.Builder().url(url).build() + val response = OkHttpClient().newCall(endpoint).execute() + if (!response.isSuccessful) throw Throwable("Failed to download artifact: ${response.code}") + + return response.body.byteStream().use { input -> + val file = File.createTempFile("artifact", ".apk", activity.externalCacheDirs.first()).also { + it.deleteOnExit() + } + runCatching { + file.outputStream().use { output -> + val buffer = ByteArray(4 * 1024) + var read: Int + var totalRead = 0L + val totalSize = response.body.contentLength() + while (input.read(buffer).also { read = it } != -1) { + output.write(buffer, 0, read) + totalRead += read + progress(totalRead.toFloat() / totalSize.toFloat()) + } + } + file + }.getOrNull() + } + } + + + @Composable + @Suppress("DEPRECATION") + override fun Content() { + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + var installStage by remember { mutableStateOf(InstallStage.DOWNLOADING) } + var downloadProgress by remember { mutableFloatStateOf(-1f) } + var downloadedFile by remember { mutableStateOf<File?>(null) } + + LaunchedEffect(Unit) { + uninstallPackageCallback = null + installPackageCallback = null + } + + val downloadPath = getArguments()?.getString("downloadPath") ?: return + val appPackage = getArguments()?.getString("appPackage") ?: return + val shouldUninstall = getArguments()?.getBoolean("uninstall") ?: false + + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + if (installStage != InstallStage.DONE && installStage != InstallStage.ERROR) { + CircularProgressIndicator() + } + } + + when (installStage) { + InstallStage.DOWNLOADING -> { + Text(text = "Downloading ...") + LinearProgressIndicator(progress = downloadProgress, Modifier.fillMaxWidth().height(4.dp), strokeCap = StrokeCap.Round) + } + InstallStage.UNINSTALLING -> { + Text(text = "Uninstalling app $appPackage...") + } + InstallStage.INSTALLING -> { + Text(text = "Installing ...") + } + InstallStage.DONE -> { + LaunchedEffect(Unit) { + navigation.navigateTo(HomeTab::class, noHistory = true) + Toast.makeText(context, "Successfully installed $appPackage!", Toast.LENGTH_SHORT).show() + } + } + InstallStage.ERROR -> Text(text = "Failed to install $appPackage. Check logcat for more details.") + } + } + + fun installPackage() { + installStage = InstallStage.INSTALLING + installPackageCallback = resultCallbacks@{ code, _ -> + installStage = if (code != ComponentActivity.RESULT_OK) { + InstallStage.ERROR + } else { + InstallStage.DONE + } + downloadedFile?.delete() + } + + installPackageIntentLauncher.launch(Intent(Intent.ACTION_INSTALL_PACKAGE).apply { + data = FileProvider.getUriForFile(context, "me.rhunk.snapenhance.manager.provider", downloadedFile!!) + setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + putExtra(Intent.EXTRA_RETURN_RESULT, true) + }) + } + + + LaunchedEffect(Unit) { + coroutineScope.launch(Dispatchers.IO) { + runCatching { + downloadedFile = downloadArtifact(downloadPath) { downloadProgress = it } ?: run { + installStage = InstallStage.ERROR + return@launch + } + if (shouldUninstall) { + installStage = InstallStage.UNINSTALLING + val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE).apply { + data = "package:$appPackage".toUri() + putExtra(Intent.EXTRA_RETURN_RESULT, true) + } + uninstallPackageCallback = resultCallback@{ resultCode, _ -> + if (resultCode != ComponentActivity.RESULT_OK) { + installStage = InstallStage.ERROR + downloadedFile?.delete() + return@resultCallback + } + installPackage() + } + uninstallPackageIntentLauncher.launch(intent) + } else { + installPackage() + } + }.onFailure { + it.printStackTrace() + installStage = InstallStage.ERROR + downloadedFile?.delete() + } + } + } + } +} diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/LSPatchTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/LSPatchTab.kt @@ -0,0 +1,197 @@ +package me.rhunk.snapenhance.manager.ui.tab.download + +import android.os.Bundle +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.manager.data.APKMirror +import me.rhunk.snapenhance.manager.data.DownloadItem +import me.rhunk.snapenhance.manager.lspatch.LSPatch +import me.rhunk.snapenhance.manager.ui.Tab +import me.rhunk.snapenhance.manager.ui.components.DowngradeNoticeDialog +import okio.use +import java.io.File + +class LSPatchTab : Tab("lspatch") { + private lateinit var downloadItem: DownloadItem + private var snapEnhanceModule: File? = null + private var patchedApk by mutableStateOf<File?>(null) + private val apkMirror = APKMirror() + + private fun patch(log: (Any?) -> Unit, onProgress: (Float) -> Unit) { + log("Fetching download link for ${downloadItem.title}...") + val downloadLink = apkMirror.fetchDownloadLink(downloadItem.downloadPage) ?: run { + log("== Failed to fetch download link ==") + return + } + + log("Downloading apk...") + + val downloadResponse = apkMirror.okhttpClient.newCall( + okhttp3.Request.Builder() + .url(downloadLink) + .build() + ).execute() + + if (!downloadResponse.isSuccessful) { + log("== Failed to download apk ==") + log("Response code: ${downloadResponse.code}") + return + } + + val apkFile = sharedConfig.apkCache.resolve("${downloadItem.hash}.apk") + apkFile.outputStream().use { outputStream -> + runCatching { + downloadResponse.body.byteStream().use { inputStream -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var read: Int + var totalRead = 0L + val totalSize = downloadResponse.body.contentLength() + while (inputStream.read(buffer).also { read = it } != -1) { + outputStream.write(buffer, 0, read) + totalRead += read + onProgress(totalRead.toFloat() / totalSize.toFloat()) + } + } + }.onFailure { + log("== Failed to download apk ==") + log(it) + return + } + } + + apkFile.renameTo(File(activity.externalCacheDir!!, "base.apk")) + + log("== Downloaded apk ==") + snapEnhanceModule?.let { module -> + val lsPatch = LSPatch(activity, mapOf( + sharedConfig.snapEnhancePackageName to module, + ), printLog = { + log("[LSPatch] $it") + }) + + log("== Patching apk ==") + val outputFiles = lsPatch.patchSplits(listOf(apkFile)) + + patchedApk = outputFiles["base.apk"] ?: run { + log("== Failed to patch apk ==") + return + } + return@let + } + patchedApk = apkFile + } + + @Composable + @Suppress("DEPRECATION") + override fun Content() { + this.downloadItem = remember { getArguments()?.getParcelable<DownloadItem>("downloadItem") } ?: return + this.snapEnhanceModule = remember { + getArguments()?.getString("modulePath")?.let { + File(it) + } + } + + val coroutineScope = rememberCoroutineScope() + var showDowngradeNoticeDialog by remember { mutableStateOf(false) } + + var status by remember { mutableStateOf("") } + var progress by remember { mutableFloatStateOf(-1f) } + + LaunchedEffect(this.downloadItem.hash) { + patchedApk = null + coroutineScope.launch(Dispatchers.IO) { + runCatching { + patch(log = { + coroutineScope.launch { + status += when (it) { + is Throwable -> it.message + "\n" + it.stackTraceToString() + else -> it.toString() + } + "\n" + } + }) { + progress = it + } + }.onFailure { + coroutineScope.launch { + status += it.message + "\n" + it.stackTraceToString() + } + } + } + } + + val scrollState = rememberScrollState() + + fun triggerInstallation(shouldUninstall: Boolean) { + navigation.navigateTo(InstallPackageTab::class, args = Bundle().apply { + putString("downloadPath", patchedApk?.absolutePath) + putString("appPackage", sharedConfig.snapchatPackageName) + putBoolean("uninstall", shouldUninstall) + }) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Card( + modifier = Modifier + .weight(1f) + .padding(10.dp), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + ) { + Text(text = status, overflow = TextOverflow.Visible, modifier = Modifier.padding(10.dp)) + } + } + if (progress != -1f) { + LinearProgressIndicator(progress = progress, modifier = Modifier.height(10.dp), strokeCap = StrokeCap.Round) + } + + if (patchedApk != null) { + Button(modifier = Modifier.fillMaxWidth(), onClick = { + triggerInstallation(true) + }) { + Text(text = "Uninstall & Install") + } + + Button(modifier = Modifier.fillMaxWidth(), onClick = { + showDowngradeNoticeDialog = true + }) { + Text(text = "Update") + } + } + + LaunchedEffect(status) { + scrollState.scrollTo(scrollState.maxValue) + } + } + + if (showDowngradeNoticeDialog) { + Dialog(onDismissRequest = { showDowngradeNoticeDialog = false }) { + DowngradeNoticeDialog(onDismiss = { showDowngradeNoticeDialog = false }, onSuccess = { + triggerInstallation(false) + }) + } + } + } +}+ \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/SEDownloadTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/SEDownloadTab.kt @@ -5,7 +5,11 @@ import androidx.activity.ComponentActivity import androidx.compose.foundation.background import androidx.compose.foundation.border 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.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -20,22 +24,20 @@ import androidx.compose.ui.window.Dialog import com.google.gson.JsonParser import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import me.rhunk.snapenhance.manager.BuildConfig import me.rhunk.snapenhance.manager.data.download.SEArtifact import me.rhunk.snapenhance.manager.data.download.SEVersion import me.rhunk.snapenhance.manager.ui.Tab +import me.rhunk.snapenhance.manager.ui.components.DowngradeNoticeDialog import okhttp3.OkHttpClient import okhttp3.Request import java.text.SimpleDateFormat import java.util.Locale -class SEDownloadTab : Tab("se_downloads") { +class SEDownloadTab : Tab("se_download") { private fun fetchSEReleases(): List<SEVersion>? { return runCatching { - val endpoint = - Request.Builder().url("https://api.github.com/repos/rhunk/SnapEnhance/releases") - .build() + val endpoint = Request.Builder().url("https://api.github.com/repos/rhunk/SnapEnhance/releases").build() val response = OkHttpClient().newCall(endpoint).execute() if (!response.isSuccessful) return null @@ -69,36 +71,6 @@ class SEDownloadTab : Tab("se_downloads") { override fun init(activity: ComponentActivity) { super.init(activity) - registerNestedTab(InstallAppTab::class) - } - - @Composable - private fun DowngradeNoticeDialog(onDismiss: () -> Unit, onSuccess: () -> Unit) { - Card { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text(text = "Downgrade Notice", fontSize = 24.sp) - Text(text = "You are about to update the app. If you're installing an older version over a newer one, make sure you have CorePatch installed. Otherwise, you will need to uninstall and install.", fontSize = 12.sp) - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceAround - ) { - Button(onClick = onDismiss) { - Text(text = "Cancel") - } - Button(onClick = onSuccess) { - Text(text = "Continue") - } - } - } } @Composable @@ -110,20 +82,16 @@ class SEDownloadTab : Tab("se_downloads") { var selectedVersion by remember { mutableStateOf(null as SEVersion?) } var selectedArtifact by remember { mutableStateOf(null as SEArtifact?) } - val appPackageName = remember { sharedConfig.snapEnhancePackageName ?: BuildConfig.APPLICATION_ID } - val isAppInstalled = remember { runCatching { activity.packageManager.getPackageInfo(appPackageName, 0) != null }.getOrNull() != null } + val isAppInstalled = remember { runCatching { activity.packageManager.getPackageInfo(sharedConfig.snapEnhancePackageName, 0) != null }.getOrNull() != null } var showDowngradeNotice by remember { mutableStateOf(false) } fun triggerPackageInstallation(shouldUninstall: Boolean) { - navigation.navigateTo(InstallAppTab::class, Bundle().apply { - putParcelable("artifact", selectedArtifact) - putString( - "appPackage", - sharedConfig.snapEnhancePackageName ?: BuildConfig.APPLICATION_ID - ) + navigation.navigateTo(InstallPackageTab::class, Bundle().apply { + putString("downloadPath", selectedArtifact?.downloadUrl) + putString("appPackage", sharedConfig.snapEnhancePackageName) putBoolean("uninstall", shouldUninstall) - }) + }, noHistory = true) } if (showDowngradeNotice) { @@ -141,12 +109,12 @@ class SEDownloadTab : Tab("se_downloads") { verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Text(text = "SnapEnhance Builds") + Text(text = "Choose SnapEnhance version") LazyColumn( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(0.8f), + .weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp) ) { item { diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/SnapchatPatchTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/download/SnapchatPatchTab.kt @@ -1,12 +1,13 @@ package me.rhunk.snapenhance.manager.ui.tab.download +import android.os.Bundle +import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.DeleteForever -import androidx.compose.material.icons.filled.Download import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -14,7 +15,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -25,73 +25,14 @@ import me.rhunk.snapenhance.manager.ui.Tab import me.rhunk.snapenhance.manager.ui.components.ConfirmationDialog @OptIn(ExperimentalMaterial3Api::class) -class SnapchatPatchTab : Tab("snapchat_download_tab") { - private val apkCache by lazy { - activity.cacheDir.resolve("snapchat_apk_cache").also { - if (!it.exists()) it.mkdirs() - } - } +class SnapchatPatchTab : Tab("snapchat_download") { private val apkMirror = APKMirror() private val cachedDownloadItems = mutableListOf<DownloadItem>() private var currentPage by mutableIntStateOf(1) - private fun isDownloaded(hash: String): Boolean { - return apkCache.resolve("${hash}.apk").exists() - } - - private fun deleteDownload(hash: String) { - runCatching { - apkCache.resolve("${hash}.apk").delete() - }.onFailure { - it.printStackTrace() - toast("Failed to delete download. It may have already been deleted.") - } - } - - private suspend fun downloadSnapchatVersion(downloadItem: DownloadItem, onProgress: (Float) -> Unit, onSuccess: suspend () -> Unit) { - withContext(Dispatchers.IO) { - toast("Downloading ${downloadItem.title}...") - val downloadLink = apkMirror.fetchDownloadLink(downloadItem.downloadPage) ?: run { - toast("Failed to fetch download link") - return@withContext - } - - val downloadResponse = apkMirror.okhttpClient.newCall( - okhttp3.Request.Builder() - .url(downloadLink) - .build() - ).execute() - - if (!downloadResponse.isSuccessful) { - toast("Failed to download") - return@withContext - } - - val apkFile = apkCache.resolve("${downloadItem.hash}.apk") - apkFile.outputStream().use { outputStream -> - runCatching { - downloadResponse.body.byteStream().use { inputStream -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var read: Int - var totalRead = 0L - val totalSize = downloadResponse.body.contentLength() - while (inputStream.read(buffer).also { read = it } != -1) { - outputStream.write(buffer, 0, read) - totalRead += read - onProgress(totalRead.toFloat() / totalSize.toFloat()) - } - } - }.onFailure { - it.printStackTrace() - toast("Failed to save apk") - return@withContext - } - } - - withContext(Dispatchers.Main) { - onSuccess() - } - } + override fun init(activity: ComponentActivity) { + super.init(activity) + registerNestedTab(LSPatchTab::class) } @Composable @@ -106,7 +47,7 @@ class SnapchatPatchTab : Tab("snapchat_download_tab") { ConfirmationDialog(title = "Are you sure you want to delete all downloads?", onDismiss = { deleteAllDialog = false }) { deleteAllDialog = false runCatching { - apkCache.listFiles()?.forEach { it.deleteRecursively() } + sharedConfig.apkCache.listFiles()?.forEach { it.deleteRecursively() } }.onFailure { toast("Failed to delete downloads") it.printStackTrace() @@ -118,25 +59,7 @@ class SnapchatPatchTab : Tab("snapchat_download_tab") { } @Composable - private fun DownloadItemRow(coroutineScope: CoroutineScope, item: DownloadItem) { - var isDownloading by remember { mutableStateOf(false) } - var downloadProgress by remember { mutableFloatStateOf(-1f) } - var isDownloaded by remember { mutableStateOf(isDownloaded(item.hash)) } - - var showDeleteCurrentDownloadDialog by remember { mutableStateOf(false) } - - if (showDeleteCurrentDownloadDialog) { - AlertDialog(onDismissRequest = { showDeleteCurrentDownloadDialog = false }) { - ConfirmationDialog(title = "Are you sure you want to delete this download?", onDismiss = { showDeleteCurrentDownloadDialog = false }) { - coroutineScope.launch { - deleteDownload(item.hash) - isDownloaded = false - } - showDeleteCurrentDownloadDialog = false - } - } - } - + private fun DownloadItemRow(item: DownloadItem, onSelected: () -> Unit = {}) { ElevatedCard( modifier = Modifier.padding(10.dp), ) { @@ -157,7 +80,6 @@ class SnapchatPatchTab : Tab("snapchat_download_tab") { verticalArrangement = Arrangement.spacedBy(5.dp) ) { Text(item.shortTitle) - Text(item.releaseDate) if (!item.isBeta) { Text("Recommended", color = MaterialTheme.colorScheme.tertiary) } @@ -166,34 +88,54 @@ class SnapchatPatchTab : Tab("snapchat_download_tab") { modifier = Modifier.padding(5.dp), horizontalArrangement = Arrangement.spacedBy(5.dp), ) { - if (!isDownloaded) { - if (isDownloading) { - if (downloadProgress != -1f) { - CircularProgressIndicator(progress = downloadProgress, modifier = Modifier.size(40.dp)) - } else { - CircularProgressIndicator(modifier = Modifier.size(40.dp)) - } - } else { - FilledIconButton(onClick = { - coroutineScope.launch { - isDownloading = true - downloadProgress = -1f - downloadSnapchatVersion(item, onProgress = { - downloadProgress = it - }, onSuccess = { isDownloaded = true }) - isDownloading = false + FilledIconButton(onClick = { onSelected() }) { + Icon(imageVector = Icons.Default.Check, contentDescription = null) + } + } + } + } + } + + + @Composable + private fun SelectSnapchatVersionDialog(onSelected: (DownloadItem) -> Unit = {}) { + val coroutineScope = rememberCoroutineScope() + var isFetching by remember { mutableStateOf(false) } + val downloadItems = remember { cachedDownloadItems.toMutableStateList() } + + LazyColumn { + items(downloadItems, key = { it.hash }) { item -> + DownloadItemRow(item) { + onSelected(item) + } + } + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.alpha(if (isFetching) 1f else 0f)) + } + SideEffect { + if (isFetching) return@SideEffect + isFetching = true + coroutineScope.launch { + runCatching { + withContext(Dispatchers.IO) { + apkMirror.fetchSnapchatVersions(currentPage)?.let { + withContext(Dispatchers.Main) { + cachedDownloadItems.addAll(it) + downloadItems.addAll(it) + } } - }) { - Icon(imageVector = Icons.Default.Download, contentDescription = null) } + }.onFailure { + it.printStackTrace() } - } else { - Button(onClick = { /*TODO*/ }) { - Text("Patch") - } - IconButton(onClick = { showDeleteCurrentDownloadDialog = true }) { - Icon(imageVector = Icons.Default.Delete, contentDescription = null) - } + ++currentPage + isFetching = false } } } @@ -202,9 +144,19 @@ class SnapchatPatchTab : Tab("snapchat_download_tab") { @Composable override fun Content() { - val coroutineScope = rememberCoroutineScope() - var isFetching by remember { mutableStateOf(false) } - val downloadItems = remember { cachedDownloadItems.toMutableStateList() } + var showSelectSnapchatVersionDialog by remember { mutableStateOf(false) } + var selectedSnapchatVersion by remember { mutableStateOf(null as DownloadItem?) } + val installedSnapEnhanceVersion = remember { runCatching { activity.packageManager.getPackageInfo( + sharedConfig.snapEnhancePackageName, 0) }.getOrNull() } + + if (showSelectSnapchatVersionDialog) { + AlertDialog(onDismissRequest = { showSelectSnapchatVersionDialog = false }) { + SelectSnapchatVersionDialog { + selectedSnapchatVersion = it + showSelectSnapchatVersionDialog = false + } + } + } Column( modifier = Modifier.fillMaxSize(), @@ -212,39 +164,70 @@ class SnapchatPatchTab : Tab("snapchat_download_tab") { verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text("Select a version to download and patch") - LazyColumn { - items(downloadItems, key = { it.hash }) { item -> - DownloadItemRow(coroutineScope, item) + + ElevatedCard( + modifier = Modifier.padding(10.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painter = painterResource(R.drawable.sclogo), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(40.dp)) + if (selectedSnapchatVersion == null) { + Text(text = "Snapchat") + } + Text(text = selectedSnapchatVersion?.shortTitle ?: "Not Selected") + Button(onClick = { showSelectSnapchatVersionDialog = true }) { + Text("Choose") + } + } + } + + ElevatedCard( + modifier = Modifier.padding(10.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "SnapEnhance") + Text(text = installedSnapEnhanceVersion?.versionName ?: "Not installed") } - item { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(modifier = Modifier.alpha(if (isFetching) 1f else 0f)) + } + + Column( + modifier = Modifier.padding(top = 10.dp, bottom = 10.dp, start = 20.dp, end = 20.dp), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Button( + modifier = Modifier.fillMaxWidth(), + enabled = selectedSnapchatVersion != null && installedSnapEnhanceVersion != null, + onClick = { + navigation.navigateTo(LSPatchTab::class, args = Bundle().apply { + putParcelable("downloadItem", selectedSnapchatVersion) + putString("modulePath", installedSnapEnhanceVersion?.applicationInfo?.sourceDir) + }, noHistory = true) } - SideEffect { - if (isFetching) return@SideEffect - isFetching = true - coroutineScope.launch { - runCatching { - withContext(Dispatchers.IO) { - apkMirror.fetchSnapchatVersions(currentPage)?.let { - withContext(Dispatchers.Main) { - cachedDownloadItems.addAll(it) - downloadItems.addAll(it) - } - } - } - }.onFailure { - it.printStackTrace() - } - ++currentPage - isFetching = false - } + ) { + Text("Download & Patch") + } + + Button( + modifier = Modifier.fillMaxWidth(), + enabled = selectedSnapchatVersion != null, + onClick = { + navigation.navigateTo(LSPatchTab::class, args = Bundle().apply { + putParcelable("downloadItem", selectedSnapchatVersion) + }, noHistory = true) } + ) { + Text("Install/Restore Original Snapchat") } } }