feat(manager): SE build downloader

This commit is contained in:
rhunk
2023-10-23 18:50:36 +02:00
parent abdb95c402
commit b651094475
14 changed files with 980 additions and 81 deletions

View File

@@ -3,6 +3,7 @@ import com.android.build.gradle.internal.api.BaseVariantOutputImpl
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.kotlinAndroid)
id("kotlin-parcelize")
}
android {
@@ -74,6 +75,7 @@ dependencies {
implementation(libs.guava)
implementation(libs.apksig)
implementation(libs.gson)
implementation(libs.okhttp)
implementation(libs.androidx.material3)
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.navigation.compose)

View File

@@ -5,10 +5,13 @@
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<application
android:label="SE Manager"
tools:targetApi="34"
android:enableOnBackInvokedCallback="true"
android:icon="@android:drawable/ic_input_add">
<activity android:name=".ui.MainActivity" android:exported="true">
<intent-filter>
@@ -16,5 +19,15 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="me.rhunk.snapenhance.manager.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,15 @@
package me.rhunk.snapenhance.manager.data
import android.content.Context
class SharedConfig(
context: Context
) {
private val sharedPreferences = context.getSharedPreferences("snapenhance", Context.MODE_PRIVATE)
var snapchatPackageName get() = sharedPreferences.getString("snapchatPackageName", "com.snapchat.android")?.takeIf { it.isNotEmpty() }
set(value) = sharedPreferences.edit().putString("snapchatPackageName", value).apply()
var snapEnhancePackageName get() = sharedPreferences.getString("snapEnhancePackageName", "me.rhunk.snapenhance")?.takeIf { it.isNotEmpty() }
set(value) = sharedPreferences.edit().putString("snapEnhancePackageName", value).apply()
}

View File

@@ -0,0 +1,9 @@
package me.rhunk.snapenhance.manager.data.download
enum class InstallStage {
DOWNLOADING,
UNINSTALLING,
INSTALLING,
DONE,
ERROR;
}

View File

@@ -0,0 +1,11 @@
package me.rhunk.snapenhance.manager.data.download
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class SEArtifact(
val fileName: String,
val size: Long,
val downloadUrl: String,
) : Parcelable

View File

@@ -0,0 +1,7 @@
package me.rhunk.snapenhance.manager.data.download
data class SEVersion(
val versionName: String,
val releaseDate: String,
val downloadAssets: Map<String, SEArtifact>,
)

View File

@@ -4,106 +4,66 @@ import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.material3.*
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.manager.BuildConfig
import me.rhunk.snapenhance.manager.lspatch.LSPatch
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
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
class MainActivity : ComponentActivity() {
companion object{
private val primaryTabs = listOf(HomeTab::class, SettingsTab::class)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val tabs = primaryTabs.mapNotNull {
runCatching { it.java.constructors.first().newInstance() as Tab }.getOrNull()
}.toMutableList().apply {
forEach { it.init(this@MainActivity) }
fun addNestedTabsRecursively(tabs: List<Tab>) {
tabs.forEach { tab ->
add(tab)
addNestedTabsRecursively(tab.nestedTabs)
}
}
toList().forEach { addNestedTabsRecursively(it.nestedTabs) }
}
setContent {
val coroutineScope = rememberCoroutineScope()
MaterialTheme(
colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (isSystemInDarkTheme()) dynamicDarkColorScheme(LocalContext.current)
else dynamicLightColorScheme(LocalContext.current)
} else MaterialTheme.colorScheme
} else darkColorScheme()
) {
val context = LocalContext.current
val logs = remember { mutableStateListOf<String>() }
fun printLog(data: Any) {
when (data) {
is Throwable -> {
logs += data.message.toString()
logs += StringWriter().apply {
data.printStackTrace(PrintWriter(this))
}.toString()
val navHostController = rememberNavController()
val sharedConfig = remember { SharedConfig(this) }
val navigation = remember {
Navigation(
navHostController = navHostController,
tabs = tabs,
defaultTab = HomeTab::class
).also {
tabs.forEach { tab ->
tab.navigation = it
tab.sharedConfig = sharedConfig
}
else -> logs += data.toString()
}
}
val scrollState = rememberLazyListState(0)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
modifier = Modifier.padding(10.dp)
Scaffold(
bottomBar = { navigation.BottomBar() },
topBar = { navigation.TopBar() },
floatingActionButton = { navigation.FloatingActionButtons() },
floatingActionButtonPosition = FabPosition.End,
) {
Text(text = "SE Manager")
Button(onClick = {
coroutineScope.launch(Dispatchers.IO) {
runCatching {
val lspatch = LSPatch(
context,
mapOf(
BuildConfig.APPLICATION_ID to File(context.packageManager.getPackageInfo(
BuildConfig.APPLICATION_ID, 0).applicationInfo.sourceDir)
)
) { printLog(it) }
lspatch.patch(
File(context.packageManager.getPackageInfo("com.snapchat.android", 0).applicationInfo.sourceDir),
File(context.filesDir, "patched.apk")
)
}.onFailure { printLog(it) }
}
}) {
Text(text = "Test patch apk")
}
LazyColumn(
state = scrollState,
modifier = Modifier.fillMaxWidth().padding(5.dp).height(500.dp).border(1.dp, color = Color.Black),
content = {
items(logs) {
Text(text = it, modifier = Modifier.padding(2.dp))
}
}
)
LaunchedEffect(logs.size) {
scrollState.scrollToItem((logs.size - 1).coerceAtLeast(0))
}
navigation.NavigationHost(it)
}
}
}

View File

@@ -0,0 +1,120 @@
package me.rhunk.snapenhance.manager.ui
import android.os.Bundle
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.sp
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState
import kotlin.reflect.KClass
class Navigation(
val navHostController: NavHostController,
private val tabs: List<Tab>,
private val defaultTab: KClass<out Tab>
) {
@Composable
fun TopBar() {
}
@Composable
fun FloatingActionButtons() {
}
fun navigateTo(tab: KClass<out Tab>) {
navHostController.navigate(tabs.first { it::class == tab }.route)
}
fun navigateTo(tab: KClass<out Tab>, noHistory: Boolean) {
navHostController.navigate(tabs.first { it::class == tab }.route) {
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)
}
@Composable
fun NavigationHost(
innerPadding: PaddingValues
) {
NavHost(
navHostController,
startDestination = tabs.first { it::class == defaultTab }.route,
Modifier.padding(innerPadding),
enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) }
) {
tabs.forEach { tab ->
tab.build(this)
}
}
}
@Composable
fun BottomBar() {
NavigationBar {
val navBackStackEntry by navHostController.currentBackStackEntryAsState()
remember { tabs.filter { it.isPrimary } }.forEach { tab ->
val tabSubRoutes = remember { tab.nestedTabs.map { it.route } }
NavigationBarItem(
selected = navBackStackEntry?.destination?.hierarchy?.any { it.route == tab.route || tabSubRoutes.contains(it.route) } == true,
alwaysShowLabel = false,
modifier = Modifier.fillMaxHeight(),
icon = {
Icon(imageVector = tab.icon!!, contentDescription = null)
},
label = {
Text(
textAlign = TextAlign.Center,
softWrap = false,
fontSize = 12.sp,
modifier = Modifier.wrapContentWidth(unbounded = true),
text = tab.route
)
},
onClick = {
navHostController.navigate(tab.route) {
popUpTo(navHostController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
}

View File

@@ -0,0 +1,49 @@
package me.rhunk.snapenhance.manager.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import me.rhunk.snapenhance.manager.data.SharedConfig
import kotlin.reflect.KClass
open class Tab(
val route: String,
val isPrimary: Boolean = false,
val icon: ImageVector? = null,
) {
lateinit var activity: ComponentActivity
val nestedTabs = mutableListOf<Tab>()
fun getNestedTab(tab: KClass<out Tab>) = nestedTabs.firstOrNull { it::class == tab }
fun registerNestedTab(tab: KClass<out Tab>) = nestedTabs.add((tab.java.constructors.first().newInstance() as Tab).also {
it.init(activity)
})
lateinit var navigation: Navigation
lateinit var sharedConfig: SharedConfig
fun getArguments() = navigation.navHostController.previousBackStackEntry?.savedStateHandle?.get<Bundle>("args")
open fun init(activity: ComponentActivity) {
this.activity = activity
}
open fun build(navGraphBuilder: NavGraphBuilder) {
navGraphBuilder.composable(route) {
Content()
}
}
@Composable
open fun TopBar() {}
@Composable
open fun FloatingActionButtons() {}
@Composable
open fun Content() {}
}

View File

@@ -0,0 +1,139 @@
package me.rhunk.snapenhance.manager.ui.tab
import android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE
import android.content.pm.PackageInfo
import androidx.activity.ComponentActivity
import androidx.compose.foundation.clickable
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
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
class HomeTab : Tab("home", true, icon = Icons.Default.Home) {
override fun init(activity: ComponentActivity) {
super.init(activity)
registerNestedTab(SEDownloadTab::class)
}
@Composable
override fun Content() {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var snapchatAppInfo by remember { mutableStateOf(null as PackageInfo?) }
var snapEnhanceInfo by remember { mutableStateOf(null as PackageInfo?) }
Column {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = androidx.compose.ui.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)
}
}
Row(
modifier = Modifier
.weight(1f),
horizontalArrangement = Arrangement.End,
verticalAlignment = androidx.compose.ui.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 = {
}) {
Text(text = if (isLSPatched) "Repatch" else "Patch")
}
} ?: run {
Text(text = "Not installed", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface)
}
}
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
navigation.navigateTo(SEDownloadTab::class)
}
.padding(16.dp),
verticalAlignment = androidx.compose.ui.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)
}
}
Row(
modifier = Modifier
.weight(1f),
horizontalArrangement = Arrangement.End,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
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))
}
}
}
}
SideEffect {
coroutineScope.launch(Dispatchers.IO) {
runCatching {
snapchatAppInfo = runCatching {
context.packageManager.getPackageInfo(sharedConfig.snapchatPackageName ?: "com.snapchat.android", 0)
}.getOrNull()
snapEnhanceInfo = runCatching {
context.packageManager.getPackageInfo(sharedConfig.snapEnhancePackageName ?: BuildConfig.APPLICATION_ID, 0)
}.getOrNull()
}
}
}
}
}

View File

@@ -0,0 +1,117 @@
package me.rhunk.snapenhance.manager.ui.tab
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import me.rhunk.snapenhance.manager.ui.Tab
class SettingsTab : Tab("settings", isPrimary = true, icon = Icons.Default.Settings) {
@Composable
private fun ConfigEditRow(getValue: () -> String?, setValue: (String) -> Unit, label: String) {
var showDialog by remember { mutableStateOf(false) }
if (showDialog) {
val focusRequester = remember { FocusRequester() }
Dialog(onDismissRequest = {
showDialog = false
}) {
Card {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = label)
var textFieldValue by remember { mutableStateOf((getValue() ?: "").let {
TextFieldValue(it, TextRange(it.length))
}) }
TextField(
value = textFieldValue,
onValueChange = {
textFieldValue = it
},
modifier = Modifier
.focusRequester(focusRequester)
.onGloballyPositioned {
focusRequester.requestFocus()
}
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
) {
Button(onClick = {
showDialog = false
}) {
Text(text = "Cancel")
}
Button(onClick = {
setValue(textFieldValue.text)
showDialog = false
}) {
Text(text = "Save")
}
}
}
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
showDialog = true
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(text = label, fontSize = 16.sp)
Text(text = getValue() ?: "(Not specified)", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Icon(imageVector = Icons.Default.Edit, contentDescription = null, modifier = Modifier.padding(16.dp))
}
}
@Composable
override fun Content() {
Column {
Spacer(modifier = Modifier.height(16.dp))
ConfigEditRow(
getValue = { sharedConfig.snapchatPackageName },
setValue = { sharedConfig.snapchatPackageName = it },
label = "Snapchat package name"
)
ConfigEditRow(
getValue = { sharedConfig.snapEnhancePackageName },
setValue = { sharedConfig.snapEnhancePackageName = it },
label = "SnapEnhance package name"
)
}
}
}

View File

@@ -0,0 +1,176 @@
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()
}
}
}
}
}

View File

@@ -0,0 +1,277 @@
package me.rhunk.snapenhance.manager.ui.tab.download
import android.os.Bundle
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Android
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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 okhttp3.OkHttpClient
import okhttp3.Request
import java.text.SimpleDateFormat
import java.util.Locale
class SEDownloadTab : Tab("se_downloads") {
private fun fetchSEReleases(): List<SEVersion>? {
return runCatching {
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
val releases = JsonParser.parseString(response.body.string()).asJsonArray.also {
if (it.size() == 0) return null
}
val isoDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault())
releases.map { releaseObject ->
val release = releaseObject.asJsonObject
val versionName = release.getAsJsonPrimitive("tag_name").asString
val releaseDate = release.getAsJsonPrimitive("published_at").asString.let { time ->
isoDateFormat.parse(time)?.let { date ->
SimpleDateFormat("dd MMM yyyy", Locale.getDefault()).format(date)
} ?: time
}
val downloadAssets = release.getAsJsonArray("assets").associate { asset ->
val assetObject = asset.asJsonObject
SEArtifact(
fileName = assetObject.getAsJsonPrimitive("name").asString,
size = assetObject.getAsJsonPrimitive("size").asLong,
downloadUrl = assetObject.getAsJsonPrimitive("browser_download_url").asString
).let { it.fileName to it }
}
SEVersion(versionName, releaseDate, downloadAssets)
}
}.onFailure {
it.printStackTrace()
}.getOrNull()
}
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
override fun Content() {
val coroutineScope = rememberCoroutineScope()
val snapEnhanceReleases = remember {
mutableStateOf(null as List<SEVersion>?)
}
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 }
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
)
putBoolean("uninstall", shouldUninstall)
})
}
if (showDowngradeNotice) {
Dialog(onDismissRequest = { showDowngradeNotice = false }) {
DowngradeNoticeDialog(onDismiss = { showDowngradeNotice = false }, onSuccess = {
triggerPackageInstallation(false)
})
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "SnapEnhance Builds")
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.8f),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
item {
if (snapEnhanceReleases.value == null) {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
CircularProgressIndicator()
}
}
}
items(snapEnhanceReleases.value ?: listOf()) { version ->
OutlinedCard(
shape = MaterialTheme.shapes.small,
modifier = Modifier
.clickable {
selectedArtifact =
if (selectedVersion != version) null else selectedArtifact
selectedVersion = if (selectedVersion == version) null else version
},
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(text = version.versionName, fontSize = 24.sp)
Text(text = "Release ${version.releaseDate}", fontSize = 12.sp)
}
Row(
modifier = Modifier
.weight(1f),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "${version.downloadAssets.size} assets", fontSize = 12.sp)
}
}
}
selectedVersion?.takeIf { it == version }?.let { selVersion ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
selVersion.downloadAssets.values.forEach { artifact ->
Row(
modifier = Modifier
.fillMaxWidth()
.border(
shape = MaterialTheme.shapes.medium,
width = 1.dp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
.clickable {
selectedArtifact =
if (selectedArtifact == artifact) null else artifact
}
.background(
if (selectedArtifact == artifact) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.medium
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(imageVector = Icons.Default.Android, contentDescription = null, modifier = Modifier.padding(start = 2.dp, end = 2.dp))
Column(
modifier = Modifier
.padding(start = 13.dp)
) {
Text(text = artifact.fileName, fontSize = 15.sp)
Text(
text = "${artifact.size / 1024 / 1024} MB",
fontSize = 10.sp
)
}
}
}
}
}
}
}
Column(
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isAppInstalled) {
Button(
onClick = {
triggerPackageInstallation(true)
},
enabled = selectedVersion != null && selectedArtifact != null,
modifier = Modifier.fillMaxWidth()
) {
Text(text = "Uninstall & Install")
}
}
Button(
onClick = {
if (isAppInstalled) {
showDowngradeNotice = true
} else {
triggerPackageInstallation(false)
}
},
enabled = selectedVersion != null && selectedArtifact != null,
modifier = Modifier.fillMaxWidth()
) {
Text(text = if (isAppInstalled) "Update" else "Install")
}
}
}
LaunchedEffect(Unit) {
coroutineScope.launch(Dispatchers.IO) {
snapEnhanceReleases.value = fetchSEReleases()
}
}
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="external_files" path="."/>
</paths>