mirror of
https://github.com/ovrport/app.git
synced 2026-10-27 19:29:57 -04:00
feat: Initial commit
feat: Main screen feat: Patcher beginnings chore: Rename project feat: Bottom navigation bar chore: Adaptive icon feat: Shaping patching feat: Integrate aapt2 feat: Integrating ARSCLib feat: JSON manifest processing feat: Migrating to KMP feat: Generate ovrport config feat: Remove localized app names feat: Drag and drop refactor: Improve reliability chore: Prepare for release feat: Update detection chore: README and LICENSE feat: Replace smali compiler chore: Code cleanup
This commit is contained in:
commit
5fc0e8fef4
52 files changed
+3344
No files matched your search
@@ -0,0 +1,122 @@
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
val appVersion: String by project
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
androidTarget {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
jvm("desktop")
|
||||
|
||||
sourceSets {
|
||||
val desktopMain by getting
|
||||
|
||||
androidMain.dependencies {
|
||||
implementation(compose.preview)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
}
|
||||
commonMain.dependencies {
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.ui)
|
||||
implementation(compose.components.resources)
|
||||
implementation(compose.components.uiToolingPreview)
|
||||
implementation(libs.androidx.lifecycle.viewmodel)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation(libs.android.tools.build)
|
||||
implementation(libs.bouncycastle.pkix)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(fileTree("libs"))
|
||||
}
|
||||
desktopMain.dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutines.swing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "moe.crx.ovrport"
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "moe.crx.ovrport"
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = appVersion.split('.').run { get(0).toInt() * 1000000 + get(1).toInt() * 1000 + get(2).toInt() }
|
||||
versionName = appVersion
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
debugImplementation(compose.uiTooling)
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "moe.crx.ovrport.MainKt"
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguard {
|
||||
configurationFiles.from("proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "overport"
|
||||
packageVersion = appVersion
|
||||
|
||||
windows {
|
||||
shortcut = true
|
||||
perUserInstall = true
|
||||
iconFile.set(file("icons/app-icon.ico"))
|
||||
}
|
||||
linux {
|
||||
shortcut = true
|
||||
iconFile.set(file("icons/app-icon.png"))
|
||||
}
|
||||
macOS {
|
||||
dockName = "overport"
|
||||
iconFile.set(file("icons/app-icon.icns"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
Binary file not shown.
Vendored
Whitespace-only changes.
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:extractNativeLibs="true"
|
||||
android:theme="@style/Theme.Overport"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Overport">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<meta-data android:name="pico.vr.position" android:value="near"/>
|
||||
</activity>
|
||||
<meta-data android:name="isPUI" android:value="1"/>
|
||||
<meta-data android:name="pvr.vrshell.mode" android:value="1"/>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,119 @@
|
||||
package moe.crx.ovrport
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.crx.ovrport.patch.CantDecodeApkException
|
||||
import moe.crx.ovrport.patch.CantUpdateOverportException
|
||||
import moe.crx.ovrport.patch.Constants
|
||||
import moe.crx.ovrport.ui.AppContent
|
||||
import moe.crx.ovrport.ui.MainViewModel
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import overportapp.composeapp.generated.resources.*
|
||||
import overportapp.composeapp.generated.resources.Res
|
||||
import overportapp.composeapp.generated.resources.apk_file_exported
|
||||
import overportapp.composeapp.generated.resources.cant_read_apk
|
||||
import overportapp.composeapp.generated.resources.cant_update_overport
|
||||
import java.io.FileInputStream
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ActivityContent(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActivityContent(activity: MainActivity) {
|
||||
val viewModel: MainViewModel = viewModel { MainViewModel() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val cantReadApkMessage = stringResource(Res.string.cant_read_apk)
|
||||
val apkFileExportedMessage = stringResource(Res.string.apk_file_exported)
|
||||
val cantUpdateOverportMessage = stringResource(Res.string.cant_update_overport)
|
||||
val unknownErrorMessage = stringResource(Res.string.unknown_error)
|
||||
|
||||
val openApkFile = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
|
||||
coroutineScope.launch {
|
||||
if (it != null) {
|
||||
val stream = activity.contentResolver.openInputStream(it) ?: return@launch
|
||||
val fileName = it.lastPathSegment?.substringAfter(':') ?: "game"
|
||||
|
||||
stream.use { s ->
|
||||
try {
|
||||
viewModel.import(activity.cacheDir, fileName, s)
|
||||
} catch (_: CantDecodeApkException) {
|
||||
viewModel.cancel()
|
||||
snackbarHostState.showSnackbar(cantReadApkMessage)
|
||||
} catch (_: CantUpdateOverportException) {
|
||||
viewModel.cancel()
|
||||
snackbarHostState.showSnackbar(cantUpdateOverportMessage)
|
||||
} catch (_: Throwable) {
|
||||
viewModel.cancel()
|
||||
snackbarHostState.showSnackbar(unknownErrorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val saveApkFile =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument(Constants.APK_MIMETYPE)) {
|
||||
coroutineScope.launch {
|
||||
if (it != null) {
|
||||
val stream = activity.contentResolver.openOutputStream(it) ?: return@launch
|
||||
|
||||
stream.use { s ->
|
||||
viewModel.export(s)
|
||||
launch {
|
||||
snackbarHostState.showSnackbar(apkFileExportedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModel.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
AppContent(
|
||||
viewModel = viewModel.apply {
|
||||
setAppIconConverter {
|
||||
FileInputStream(it).use { stream ->
|
||||
BitmapFactory.decodeStream(stream).asImageBitmap()
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHostState = snackbarHostState,
|
||||
onOpen = {
|
||||
coroutineScope.launch {
|
||||
openApkFile.launch(arrayOf(Constants.APK_MIMETYPE))
|
||||
}
|
||||
},
|
||||
onConfirm = {
|
||||
coroutineScope.launch {
|
||||
viewModel.process(it)
|
||||
saveApkFile.launch(viewModel.patchedName())
|
||||
}
|
||||
},
|
||||
onCancel = {
|
||||
coroutineScope.launch {
|
||||
viewModel.cancel()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package moe.crx.ovrport
|
||||
|
||||
import android.os.Build
|
||||
|
||||
actual fun getPlatform(): Platform = AndroidPlatform("Android ${Build.VERSION.SDK_INT}")
|
||||
@@ -0,0 +1,14 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group android:scaleX="0.6"
|
||||
android:scaleY="0.6"
|
||||
android:translateX="4.8"
|
||||
android:translateY="4.8">
|
||||
<path
|
||||
android:pathData="m17.66,4.873l0,-0C17.963,5.048 18.042,5.452 17.824,5.73L13.874,10.77L16.906,12.52C17.668,12.96 16.816,13.335 16.784,13.351 14.578,14.652 11.291,16.565 6.926,19.125 6.749,19.231 6.521,19.226 6.348,19.126l0,-0c-0.303,-0.175 -0.382,-0.578 -0.164,-0.857L10.134,13.229l-3.04,-1.755c-0.346,-0.2 -0.442,-0.475 -0.016,-0.772 5.719,-3.326 9.048,-5.272 10.005,-5.829 0.177,-0.106 0.396,-0.106 0.578,-0.001z"
|
||||
android:fillColor="#ffffff"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#000000</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">overport</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Overport" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="77.095dp"
|
||||
android:height="16.497dp"
|
||||
android:viewportWidth="77.095"
|
||||
android:viewportHeight="16.497">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m9.849,7.944q0,1.181 -0.343,2.172 -0.343,0.972 -0.991,1.676 -0.648,0.686 -1.562,1.067 -0.895,0.381 -2.019,0.381 -1.105,0 -2.019,-0.381 -0.895,-0.381 -1.543,-1.067 -0.648,-0.705 -1.01,-1.676 -0.362,-0.991 -0.362,-2.172t0.362,-2.153q0.381,-0.972 1.029,-1.657 0.667,-0.686 1.562,-1.067 0.914,-0.381 1.981,-0.381 1.086,0 1.981,0.381 0.914,0.381 1.562,1.067t1.01,1.657 0.362,2.153zM6.953,7.944q0,-1.314 -0.533,-2.057 -0.514,-0.762 -1.486,-0.762t-1.505,0.762q-0.533,0.743 -0.533,2.057 0,1.314 0.533,2.095 0.533,0.762 1.505,0.762t1.486,-0.762q0.533,-0.781 0.533,-2.095zM14.497,12.973q-0.972,-1.905 -1.981,-4.458 -0.991,-2.553 -1.886,-5.563h3.01q0.191,0.743 0.438,1.619 0.267,0.857 0.533,1.753 0.286,0.876 0.552,1.714 0.286,0.838 0.552,1.524 0.248,-0.686 0.533,-1.524 0.286,-0.838 0.552,-1.714 0.286,-0.895 0.533,-1.753 0.267,-0.876 0.457,-1.619h2.934q-0.895,3.01 -1.905,5.563 -0.991,2.553 -1.962,4.458zM37.433,5.467q-0.381,-0.095 -0.895,-0.191 -0.514,-0.114 -1.105,-0.114 -0.267,0 -0.648,0.057 -0.362,0.038 -0.552,0.095v7.658h-2.838v-9.487q0.762,-0.267 1.791,-0.495 1.048,-0.248 2.324,-0.248 0.229,0 0.552,0.038 0.324,0.019 0.648,0.076 0.324,0.038 0.648,0.114 0.324,0.057 0.552,0.152zM45.853,8.039q0,-1.314 -0.591,-2.095 -0.591,-0.8 -1.753,-0.8 -0.381,0 -0.705,0.038 -0.324,0.019 -0.533,0.057v5.124q0.267,0.171 0.686,0.286 0.438,0.114 0.876,0.114 2.019,0 2.019,-2.724zM48.748,7.963q0,1.162 -0.286,2.115 -0.286,0.952 -0.838,1.638 -0.552,0.686 -1.372,1.067 -0.819,0.381 -1.886,0.381 -0.591,0 -1.105,-0.114 -0.514,-0.114 -0.991,-0.324v3.772h-2.838v-13.201q0.381,-0.114 0.876,-0.21 0.495,-0.114 1.029,-0.191 0.552,-0.076 1.105,-0.114 0.571,-0.057 1.086,-0.057 1.238,0 2.21,0.381 0.972,0.362 1.638,1.048 0.667,0.667 1.01,1.638 0.362,0.972 0.362,2.172zM60.311,7.944q0,1.181 -0.343,2.172 -0.343,0.972 -0.991,1.676 -0.648,0.686 -1.562,1.067 -0.895,0.381 -2.019,0.381 -1.105,0 -2.019,-0.381 -0.895,-0.381 -1.543,-1.067 -0.648,-0.705 -1.01,-1.676 -0.362,-0.991 -0.362,-2.172t0.362,-2.153q0.381,-0.972 1.029,-1.657 0.667,-0.686 1.562,-1.067 0.914,-0.381 1.981,-0.381 1.086,0 1.981,0.381 0.914,0.381 1.562,1.067 0.648,0.686 1.01,1.657 0.362,0.972 0.362,2.153zM57.416,7.944q0,-1.314 -0.533,-2.057 -0.514,-0.762 -1.486,-0.762 -0.972,0 -1.505,0.762 -0.533,0.743 -0.533,2.057 0,1.314 0.533,2.095 0.533,0.762 1.505,0.762 0.972,0 1.486,-0.762 0.533,-0.781 0.533,-2.095zM68.541,5.467q-0.381,-0.095 -0.895,-0.191 -0.514,-0.114 -1.105,-0.114 -0.267,0 -0.648,0.057 -0.362,0.038 -0.552,0.095v7.658h-2.838v-9.487q0.762,-0.267 1.791,-0.495 1.048,-0.248 2.324,-0.248 0.229,0 0.552,0.038 0.324,0.019 0.648,0.076 0.324,0.038 0.648,0.114 0.324,0.057 0.552,0.152zM70.446,0.457 L73.284,-0v2.953h3.41v2.362h-3.41v3.524q0,0.895 0.305,1.429 0.324,0.533 1.276,0.533 0.457,0 0.933,-0.076 0.495,-0.095 0.895,-0.248l0.4,2.21q-0.514,0.21 -1.143,0.362 -0.629,0.152 -1.543,0.152 -1.162,0 -1.924,-0.305 -0.762,-0.324 -1.219,-0.876 -0.457,-0.571 -0.648,-1.372 -0.171,-0.8 -0.171,-1.772z"
|
||||
android:strokeWidth=".26458"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m29.849,2.442c0.234,0.135 0.295,0.446 0.126,0.661l-3.046,3.888 2.338,1.35c0.588,0.339 -0.069,0.628 -0.094,0.641 -1.702,1.004 -4.236,2.479 -7.603,4.454 -0.136,0.082 -0.312,0.078 -0.446,0.001 -0.234,-0.135 -0.295,-0.446 -0.126,-0.661l3.046,-3.888 -2.344,-1.354c-0.267,-0.154 -0.341,-0.366 -0.013,-0.595 4.411,-2.565 6.978,-4.066 7.716,-4.495 0.136,-0.082 0.306,-0.082 0.446,-0.001z"
|
||||
android:strokeWidth=".77121"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="m21.421,0.131c0.505,0.291 0.636,0.964 0.273,1.427l-6.578,8.396l5.049,2.915c1.269,0.733 -0.149,1.357 -0.202,1.383 -3.675,2.167 -9.149,5.354 -16.42,9.618 -0.294,0.176 -0.674,0.168 -0.963,0.002 -0.505,-0.291 -0.636,-0.964 -0.273,-1.427l6.578,-8.396l-5.063,-2.923c-0.577,-0.333 -0.736,-0.79 -0.027,-1.285 9.526,-5.54 15.072,-8.782 16.664,-9.708 0.294,-0.176 0.66,-0.177 0.963,-0.002z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<resources>
|
||||
<string name="select_apk_to_begin">Select an APK to begin</string>
|
||||
<string name="or_drag_and_drop_it">(or drag and drop it)</string>
|
||||
<string name="decoding_apk">Processing APK…</string>
|
||||
<string name="unknown_package"><unknown package></string>
|
||||
<string name="cant_read_apk">Selected APK file cannot be parsed.</string>
|
||||
<string name="apk_file_exported">APK file has been successfully exported.</string>
|
||||
<string name="cant_update_overport">overport libraries cannot be downloaded.</string>
|
||||
<string name="unknown_error">Unknown error has been occurred.</string>
|
||||
<string name="update_available">Update available!</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
package moe.crx.ovrport
|
||||
|
||||
interface Platform {
|
||||
val name: String
|
||||
}
|
||||
|
||||
class JVMPlatform(override val name: String): Platform
|
||||
|
||||
class AndroidPlatform(override val name: String) : Platform
|
||||
|
||||
expect fun getPlatform(): Platform
|
||||
|
||||
// TODO This should be controlled by Gradle.
|
||||
fun getCurrentVersion(): String = "1.0.0"
|
||||
@@ -0,0 +1,46 @@
|
||||
package moe.crx.ovrport.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class FrdaAsset(
|
||||
@SerialName("ID")
|
||||
val id: Long,
|
||||
val path: String,
|
||||
val type: String = "store"
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FrdaParameters(
|
||||
val assets: List<FrdaAsset>,
|
||||
val skus: List<String>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FrdaInteraction(
|
||||
val parameters: FrdaParameters
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FrdaConfig(
|
||||
val interaction: FrdaInteraction
|
||||
)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun generateConfig(jsonString: String): String {
|
||||
return json.decodeFromString<FrdaConfig>(jsonString).let { config ->
|
||||
json.encodeToString(
|
||||
OverportConfigV1(
|
||||
database = listOf(
|
||||
OverportConfigApplication(
|
||||
"__current__",
|
||||
config.interaction.parameters.skus,
|
||||
config.interaction.parameters.assets.map { OverportConfigAsset(it.id, it.path, it.type) })
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package moe.crx.ovrport.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class GithubRelease(
|
||||
@SerialName("html_url")
|
||||
var htmlUrl: String,
|
||||
@SerialName("name")
|
||||
val name: String,
|
||||
@SerialName("published_at")
|
||||
val publishedAt: String,
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
package moe.crx.ovrport.model
|
||||
|
||||
import kotlinx.serialization.EncodeDefault
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class OverportConfigLanguage(
|
||||
@SerialName("english_name")
|
||||
var englishName: String = "",
|
||||
@SerialName("native_name")
|
||||
var nativeName: String = "",
|
||||
@SerialName("tag")
|
||||
var tag: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OverportConfigAsset(
|
||||
@SerialName("asset_id")
|
||||
var assetId: Long = 0,
|
||||
@SerialName("file_path")
|
||||
var filePath: String,
|
||||
@SerialName("asset_type")
|
||||
var assetType: String = "store",
|
||||
@SerialName("language")
|
||||
var language: OverportConfigLanguage = OverportConfigLanguage(),
|
||||
@SerialName("download_status")
|
||||
var downloadStatus: String = "installed",
|
||||
@SerialName("iap_status")
|
||||
var iapStatus: String = "entitled",
|
||||
@SerialName("metadata")
|
||||
var metadata: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OverportConfigApplication(
|
||||
@SerialName("app_id")
|
||||
var appId: String,
|
||||
@SerialName("products")
|
||||
var products: List<String>,
|
||||
@SerialName("assets")
|
||||
var assets: List<OverportConfigAsset>,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class OverportConfigV1(
|
||||
@SerialName("version")
|
||||
@EncodeDefault
|
||||
var version: Int = 1,
|
||||
@SerialName("suggested_texture_width")
|
||||
var suggestedTextureWidth: Int = 0,
|
||||
@SerialName("suggested_texture_height")
|
||||
var suggestedTextureHeight: Int = 0,
|
||||
@SerialName("suggested_cpu_level")
|
||||
var suggestedCpuLevel: Int = 0,
|
||||
@SerialName("suggested_gpu_level")
|
||||
var suggestedGpuLevel: Int = 0,
|
||||
@SerialName("vibration_multiplier")
|
||||
var vibrationMultiplier: Double = 1.0,
|
||||
@SerialName("vibration_frequency_multiplier")
|
||||
var vibrationFrequencyMultiplier: Double = 1.0,
|
||||
@SerialName("database")
|
||||
var database: List<OverportConfigApplication>,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package moe.crx.ovrport.patch
|
||||
|
||||
object Constants {
|
||||
const val APK_MIMETYPE = "application/vnd.android.package-archive"
|
||||
const val OVRPORT_VERSION = "3.2.2"
|
||||
const val LIBRARIES_URL = "https://files.crx.moe/ovrport/tracker/releases/download/$OVRPORT_VERSION/libraries.zip"
|
||||
const val LIBRARIES_DIR = "libraries/$OVRPORT_VERSION"
|
||||
const val RELEASES_URL = "https://api.github.com/repos/ovrport/app/releases"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package moe.crx.ovrport.patch
|
||||
|
||||
class CantUpdateOverportException : RuntimeException()
|
||||
|
||||
class CantDecodeApkException : RuntimeException()
|
||||
@@ -0,0 +1,168 @@
|
||||
package moe.crx.ovrport.patch
|
||||
|
||||
import com.reandroid.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
data class Patch(
|
||||
val name: String = "",
|
||||
val desc: String? = null,
|
||||
val executor: PatchExecutor.() -> Unit
|
||||
)
|
||||
|
||||
class FileTransformer(val file: File) {
|
||||
fun replace(input: String, output: String) {
|
||||
file.writeText(Regex(input, RegexOption.DOT_MATCHES_ALL).replace(file.readText(), output))
|
||||
}
|
||||
|
||||
fun find(input: String): Boolean {
|
||||
return Regex(input, RegexOption.DOT_MATCHES_ALL).find(file.readText()) != null
|
||||
}
|
||||
|
||||
fun append(line: String) {
|
||||
file.appendText(line)
|
||||
}
|
||||
|
||||
fun readJson(): JSONObject? {
|
||||
return runCatching { JSONObject(file.readText()) }.getOrNull()
|
||||
}
|
||||
|
||||
fun writeJson(json: JSONObject) {
|
||||
file.writeText(json.toString())
|
||||
}
|
||||
|
||||
fun replaceHex(input: String, output: String) {
|
||||
val bytes = file.readBytes()
|
||||
val inputBytes = input.split(' ')
|
||||
|
||||
for (i in 0..<bytes.size-inputBytes.size+1) {
|
||||
var matches = true
|
||||
|
||||
for (j in i..<bytes.size) {
|
||||
if (inputBytes[j] != "??" && bytes[j] != inputBytes[j].toByte(16)) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
val replaced = bytes.slice(0..<i) +
|
||||
output.split(' ').map { it.toByte(16) } +
|
||||
bytes.slice(i+inputBytes.size..<bytes.size)
|
||||
file.writeBytes(replaced.toByteArray())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PatchExecutor(private val workingDir: File) {
|
||||
fun selectWorkspace(block: FileTransformer.() -> Unit) {
|
||||
workingDir.run {
|
||||
if (exists()) {
|
||||
FileTransformer(this).block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectFile(fileName: String, block: FileTransformer.() -> Unit) {
|
||||
workingDir.resolve(fileName).run {
|
||||
if (exists()) {
|
||||
FileTransformer(this).block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectResources(block: FileTransformer.() -> Unit) {
|
||||
selectFile("resources/resources.arsc.json", block)
|
||||
}
|
||||
|
||||
fun selectManifest(block: FileTransformer.() -> Unit) {
|
||||
selectFile("AndroidManifest.xml.json", block)
|
||||
}
|
||||
|
||||
fun selectLibrary(fileName: String, block: FileTransformer.() -> Unit) {
|
||||
listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64").forEach { arch ->
|
||||
val archDirectory = workingDir.resolve("root/lib/$arch")
|
||||
if (archDirectory.exists()) {
|
||||
archDirectory.resolve(fileName).run {
|
||||
if (exists()) {
|
||||
FileTransformer(this).block()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createLibrary(fileName: String, block: FileTransformer.() -> Unit) {
|
||||
listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64").forEach { arch ->
|
||||
val archDirectory = workingDir.resolve("root/lib/$arch")
|
||||
if (archDirectory.exists()) {
|
||||
archDirectory.resolve(fileName).run {
|
||||
createNewFile()
|
||||
FileTransformer(this).block()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSmali(vararg fileNames: String, block: FileTransformer.() -> Unit) {
|
||||
workingDir.resolve("smali").listFiles()?.forEach { dir ->
|
||||
if (fileNames.isEmpty()) {
|
||||
dir.walk().forEach { file ->
|
||||
if (file.isFile && file.name.endsWith(".smali")) {
|
||||
FileTransformer(file).block()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fileNames.forEach { fileName ->
|
||||
dir.resolve("${fileName}.1.smali").run {
|
||||
if (exists()) {
|
||||
FileTransformer(this).block()
|
||||
}
|
||||
}
|
||||
dir.resolve("${fileName}.smali").run {
|
||||
if (exists()) {
|
||||
FileTransformer(this).block()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createSmali(fileName: String, block: FileTransformer.() -> Unit) {
|
||||
workingDir.resolve("smali").listFiles()?.forEach { dir ->
|
||||
dir.resolve("${fileName}.1.smali").run {
|
||||
if (exists()) {
|
||||
FileTransformer(this).block()
|
||||
return
|
||||
}
|
||||
}
|
||||
dir.resolve("${fileName}.smali").run {
|
||||
if (exists()) {
|
||||
FileTransformer(this).block()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
workingDir
|
||||
.resolve("smali")
|
||||
.listFiles()
|
||||
?.reduceOrNull { left, right ->
|
||||
val leftValue = left.name.substringAfter("classes").toIntOrNull() ?: 0
|
||||
val rightValue = right.name.substringAfter("classes").toIntOrNull() ?: 0
|
||||
if (rightValue > leftValue) right else left
|
||||
}
|
||||
.let {
|
||||
it ?: workingDir.resolve("smali/classes").apply { mkdirs() }
|
||||
}
|
||||
.resolve("${fileName}.smali")
|
||||
.apply { createNewFile() }
|
||||
.run {
|
||||
if (exists()) {
|
||||
FileTransformer(this).block()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package moe.crx.ovrport.patch
|
||||
|
||||
import com.reandroid.json.JSONArray
|
||||
import com.reandroid.json.JSONObject
|
||||
import moe.crx.ovrport.model.generateConfig
|
||||
import moe.crx.ovrport.utils.*
|
||||
|
||||
object PatchStore {
|
||||
val PATCHES = mutableListOf<Patch>()
|
||||
|
||||
init {
|
||||
PATCHES += Patch("Copy ovrport libraries") {
|
||||
selectWorkspace {
|
||||
file.resolve("root/lib")
|
||||
.listFiles()
|
||||
?.forEach {
|
||||
val arch = file.parentFile
|
||||
?.parentFile
|
||||
?.resolve(Constants.LIBRARIES_DIR)
|
||||
?.resolve("lib")
|
||||
?.resolve(it.name)
|
||||
|
||||
if (arch?.exists() == true) {
|
||||
arch.copyRecursively(it, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Fix minimal Android SDK") {
|
||||
selectManifest {
|
||||
readJson().takeNodesEach({ named("manifest") }) {
|
||||
takeNodesEach({ named("uses-sdk") }) {
|
||||
takeAttributesEach({ named("minSdkVersion") }) {
|
||||
take<Int>("data") { 29 }
|
||||
}
|
||||
}
|
||||
}?.let {
|
||||
writeJson(it)
|
||||
}
|
||||
}
|
||||
selectSmali {
|
||||
replace(
|
||||
"invoke-virtual \\{(\\w+), (\\w+)\\}, Landroid/view/Window;->setDecorFitsSystemWindows\\(Z\\)V",
|
||||
""
|
||||
)
|
||||
}
|
||||
selectSmali("androidx/core/view/WindowCompat\$Api30Impl") {
|
||||
replace(
|
||||
"invoke-virtual \\{(\\w+), (\\w+)\\}, Landroid/view/Window;->setDecorFitsSystemWindows\\(Z\\)V",
|
||||
""
|
||||
)
|
||||
}
|
||||
selectSmali("androidx/core/view/WindowInsetsCompat\$TypeImpl30") {
|
||||
replace(
|
||||
"(toPlatformType\\(I\\)I.*?)\\.locals (\\w+)",
|
||||
"$0\nconst v0, 0x0\nreturn v0"
|
||||
)
|
||||
}
|
||||
selectSmali("androidx/core/view/WindowInsetsCompat\$Impl30") {
|
||||
replace(
|
||||
"(getInsets\\(I\\)Landroidx/core/graphics/Insets;.*?)\\.locals (\\w+)",
|
||||
"$0\nsget-object p0, Landroidx/core/graphics/Insets;->NONE:Landroidx/core/graphics/Insets;\nreturn-object p0"
|
||||
)
|
||||
replace("(isVisible\\(I\\)Z.*?)\\.locals (\\w+)", "$0\nconst/4 p0, 0\nreturn p0")
|
||||
}
|
||||
selectSmali("com/epicgames/unreal/GameActivity") {
|
||||
replace(
|
||||
"const-string \\w+?, \\\"vibrator_manager\\\".*?iput-object \\w+?, \\w+?, Lcom/epicgames/unreal/GameActivity;->\\w+?:Landroid/os/Vibrator;",
|
||||
""
|
||||
)
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Generate ovrport config") {
|
||||
selectLibrary("libfrda.config.so") {
|
||||
readJson()?.let { generateConfig(it.toString()) }?.let {
|
||||
createLibrary("liboverport.config.so") {
|
||||
writeJson(JSONObject(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Remove localized app names") {
|
||||
selectResources {
|
||||
readJson().take<JSONArray>("packages") {
|
||||
takeEach<JSONObject> {
|
||||
take<JSONArray>("specs") {
|
||||
takeEach<JSONObject> {
|
||||
take<JSONArray>("types") {
|
||||
takeEach<JSONObject> {
|
||||
val configLanguage = elem<JSONObject>("config").elem<String>("language")
|
||||
|
||||
take<JSONArray>("entries") {
|
||||
takeEach<JSONObject> {
|
||||
this.takeIf {
|
||||
elem<String>("entry_name") != "app_name" || configLanguage == null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}?.let {
|
||||
writeJson(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Clean up Frida") {
|
||||
selectLibrary("libfrda.so") {
|
||||
file.delete()
|
||||
}
|
||||
selectLibrary("libfrda.config.so") {
|
||||
file.delete()
|
||||
}
|
||||
selectLibrary("libscript.so") {
|
||||
file.delete()
|
||||
}
|
||||
|
||||
selectManifest {
|
||||
readJson().takeNodesEach(({ named("manifest") })) {
|
||||
takeNodesEach({ named("application") }) {
|
||||
takeNodesEach({ named("activity") }) {
|
||||
attributeName()?.replace('.', '/')?.let {
|
||||
selectSmali(it) {
|
||||
replace(
|
||||
"const-string (\\w+), \\\"frda\\\"\\s+?invoke-static \\{\\1\\}, Ljava/lang/System;->loadLibrary\\(Ljava/lang/String;\\)V",
|
||||
""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Patch Oculus detection for Unity") {
|
||||
selectSmali("com/unity/oculus/OculusUnity") {
|
||||
replace(
|
||||
"(getIsOnOculusHardware\\(\\)Z.*?)return (\\w+)",
|
||||
"$1\nconst/4 $2, 0x1\nreturn $2"
|
||||
)
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Patch Oculus detection for Unreal") {
|
||||
selectSmali(
|
||||
"com/epicgames/ue4/GameActivity",
|
||||
"com/epicgames/unreal/GameActivity",
|
||||
) {
|
||||
replace(
|
||||
"sget-object (\\w+), Landroid/os/Build;->MANUFACTURER:Ljava/lang/String;",
|
||||
"const-string $1, \"Oculus\""
|
||||
)
|
||||
replace(
|
||||
"sget-object (\\w+), Landroid/os/Build;->MODEL:Ljava/lang/String;",
|
||||
"const-string $1, \"Quest 2\""
|
||||
)
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Pico/YVR/Quest metadata") {
|
||||
selectManifest {
|
||||
readJson().takeNodesEach(({ named("manifest") })) {
|
||||
takeNodesEach({ named("application") }) {
|
||||
takeNodesEach({ named("meta-data") }) {
|
||||
if (attributeName() == "com.oculus.supportedDevices") null else this
|
||||
}
|
||||
takeNodes {
|
||||
this
|
||||
?.put(createMetadata("pvr.app.type", "vr"))
|
||||
?.put(createMetadata("com.yvr.intent.category.VR", "vr_only"))
|
||||
?.put(createMetadata("com.oculus.supportedDevices", "all"))
|
||||
}
|
||||
}
|
||||
}?.let {
|
||||
writeJson(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Fix launcher icon entry") {
|
||||
selectManifest {
|
||||
readJson().takeNodesEach(({ named("manifest") })) {
|
||||
takeNodesEach({ named("application") }) {
|
||||
takeNodesEach({ named("activity") }) {
|
||||
takeNodesEach({ named("intent-filter") }) {
|
||||
takeNodesEach({ named("category") }) {
|
||||
when (attributeName()) {
|
||||
"android.intent.category.INFO" -> null
|
||||
"android.intent.category.LAUNCHER" -> null
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
takeNodes {
|
||||
this?.put(createCategory("android.intent.category.LAUNCHER"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}?.let {
|
||||
writeJson(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Remove uses-library") {
|
||||
selectManifest {
|
||||
readJson().takeNodesEach({ named("manifest") }) {
|
||||
takeNodesEach({ named("application") }) {
|
||||
takeNodesEach({ named("uses-library") }) { null }
|
||||
takeNodesEach({ named("uses-native-library") }) { null }
|
||||
}
|
||||
}?.let {
|
||||
writeJson(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Fix Unreal Engine 4 crash") {
|
||||
createSmali("com/unity3d/player/UnityPlayer") {
|
||||
if (!find(".*")) {
|
||||
append(".class public Lcom/unity3d/player/UnityPlayer;")
|
||||
append(".super Ljava/lang/Object;")
|
||||
append(".field public static currentActivity:Landroid/app/Activity;")
|
||||
}
|
||||
}
|
||||
selectSmali("com/epicgames/ue4/GameActivity") {
|
||||
replace(
|
||||
"sput-object (\\w+), Lcom\\/epicgames\\/ue4\\/GameActivity;->\\w+:Lcom\\/epicgames\\/ue4\\/GameActivity;",
|
||||
"$0\nsput-object $1, Lcom/unity3d/player/UnityPlayer;->currentActivity:Landroid/app/Activity;"
|
||||
)
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Patch MetaXRAudioWwise") {
|
||||
selectLibrary("liblibMetaXRAudioWwise.so") {
|
||||
replaceHex(
|
||||
"57 D0 3B D5 40 EB FF F0 00 0C 2D 91 E8 16 40 F9 A8 83 1F F8 DC 7F 0B 94 00 01 00 B4 E3 03 00 AA 21 EB FF B0",
|
||||
"57 D0 3B D5 20 00 80 D2 1F 20 03 D5 E8 16 40 F9 A8 83 1F F8 1F 20 03 D5 00 01 00 B4 03 00 80 D2 21 EB FF B0"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package moe.crx.ovrport.patch
|
||||
|
||||
import com.android.apksig.ApkSigner
|
||||
import com.reandroid.apk.*
|
||||
import com.reandroid.archive.FileInputSource
|
||||
import moe.crx.ovrport.utils.HttpUtil
|
||||
import org.jf.baksmali.Baksmali
|
||||
import org.jf.baksmali.BaksmaliOptions
|
||||
import org.jf.dexlib2.Opcodes
|
||||
import org.jf.dexlib2.VersionMap
|
||||
import org.jf.dexlib2.dexbacked.DexBackedDexFile
|
||||
import org.jf.dexlib2.dexbacked.raw.HeaderItem
|
||||
import org.jf.smali.Smali
|
||||
import org.jf.smali.SmaliOptions
|
||||
import java.io.*
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption.REPLACE_EXISTING
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
data class PatcherContext(
|
||||
val patcherDirectory: File,
|
||||
val dataDir: File = patcherDirectory.resolve("dec"),
|
||||
|
||||
val fileName: String = "working",
|
||||
val inputApk: File = dataDir.resolve("$fileName.apk"),
|
||||
val outputApk: File = dataDir.resolve("$fileName.output.apk"),
|
||||
|
||||
val workingDir: File = dataDir.resolve(fileName),
|
||||
var workingApk: ApkModule? = null,
|
||||
)
|
||||
|
||||
fun PatcherContext.prepare(stream: InputStream) {
|
||||
val librariesDir = patcherDirectory.resolve(Constants.LIBRARIES_DIR)
|
||||
|
||||
if (!librariesDir.exists()) {
|
||||
val downloadedArchive = runCatching { HttpUtil.download(Constants.LIBRARIES_URL) }.getOrElse {
|
||||
librariesDir.deleteRecursively()
|
||||
throw CantUpdateOverportException()
|
||||
}
|
||||
|
||||
librariesDir.mkdirs()
|
||||
|
||||
ZipInputStream(downloadedArchive.inputStream()).use { zip ->
|
||||
generateSequence { zip.nextEntry }
|
||||
.forEach {
|
||||
if (it.isDirectory) {
|
||||
librariesDir.resolve(it.name).mkdirs()
|
||||
} else {
|
||||
zip.copyTo(FileOutputStream(librariesDir.resolve(it.name)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dataDir.mkdirs()
|
||||
|
||||
workingDir.deleteRecursively()
|
||||
inputApk.delete()
|
||||
outputApk.delete()
|
||||
|
||||
Files.copy(stream, inputApk.toPath(), REPLACE_EXISTING)
|
||||
|
||||
val apk = runCatching { ApkModule.loadApkFile(inputApk) }.getOrElse {
|
||||
throw CantDecodeApkException()
|
||||
}
|
||||
|
||||
val decoder = ApkModuleJsonDecoder(apk).apply {
|
||||
dexDecoder = DexDecoder { dexFile, dir ->
|
||||
val bytes = ByteArrayOutputStream().apply {
|
||||
dexFile.write(this)
|
||||
}.toByteArray()
|
||||
|
||||
val version = HeaderItem.getVersion(bytes, 0)
|
||||
val api = VersionMap.mapDexVersionToApi(version)
|
||||
val opcodes = Opcodes.forApi(api)
|
||||
|
||||
val classesName = dexFile.dexNumber.let { if (it != 0) "classes$it" else "classes" }
|
||||
val smaliDir = dir.resolve("smali").resolve(classesName)
|
||||
|
||||
val options = BaksmaliOptions().apply {
|
||||
localsDirective = true
|
||||
sequentialLabels = true
|
||||
skipDuplicateLineNumbers = true
|
||||
apiLevel = api
|
||||
}
|
||||
Baksmali.disassembleDexFile(DexBackedDexFile(opcodes, bytes), smaliDir, 4, options)
|
||||
}
|
||||
}
|
||||
|
||||
decoder.sanitizeFilePaths()
|
||||
decoder.decode(workingDir)
|
||||
|
||||
workingApk = apk
|
||||
}
|
||||
|
||||
fun PatcherContext.patch(patches: List<Patch>) {
|
||||
patches.forEach {
|
||||
it.executor(PatchExecutor(workingDir))
|
||||
}
|
||||
|
||||
val encoder = ApkModuleJsonEncoder().apply {
|
||||
dexEncoder = DexEncoder { _, dir ->
|
||||
dir
|
||||
.resolve("smali")
|
||||
.listFiles()
|
||||
?.filter { file -> file.isDirectory }
|
||||
?.map {
|
||||
dir.resolve(".cache").mkdirs()
|
||||
val file = dir.resolve(".cache").resolve("${it.name}.dex")
|
||||
|
||||
val smaliOptions = SmaliOptions().apply {
|
||||
outputDexFile = file.absolutePath
|
||||
jobs = 4
|
||||
apiLevel = 29
|
||||
}
|
||||
|
||||
Smali.assemble(smaliOptions, it.absolutePath)
|
||||
FileInputSource(file, file.name)
|
||||
}
|
||||
?.toList()
|
||||
}
|
||||
}
|
||||
|
||||
encoder.scanDirectory(workingDir)
|
||||
encoder.apkModule.run {
|
||||
zipEntryMap.autoSortApkFiles()
|
||||
writeApk(outputApk)
|
||||
close()
|
||||
}
|
||||
|
||||
val signatureDir = patcherDirectory.resolve("signatures")
|
||||
signatureDir.mkdirs()
|
||||
val signature = (appPackage() ?: "default")
|
||||
.plus(".keystore")
|
||||
.let { signatureDir.resolve(it) }
|
||||
.let { SignatureStore.getSignature(it) }
|
||||
|
||||
val tempFile = outputApk
|
||||
.parentFile
|
||||
?.resolve(outputApk.name + ".temp")
|
||||
?.let {
|
||||
outputApk.copyTo(it)
|
||||
}
|
||||
|
||||
try {
|
||||
ApkSigner
|
||||
.Builder(listOf(signature))
|
||||
.setAlignFileSize(true)
|
||||
.setMinSdkVersion(29)
|
||||
.setInputApk(tempFile)
|
||||
.setOutputApk(outputApk)
|
||||
.build()
|
||||
.sign()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
tempFile?.delete()
|
||||
}
|
||||
|
||||
fun PatcherContext.export(stream: OutputStream) {
|
||||
Files.copy(outputApk.toPath(), stream)
|
||||
}
|
||||
|
||||
fun PatcherContext.cleanup() {
|
||||
workingDir.deleteRecursively()
|
||||
workingApk?.close()
|
||||
workingApk = null
|
||||
inputApk.delete()
|
||||
outputApk.delete()
|
||||
}
|
||||
|
||||
fun PatcherContext.appPackage(): String? {
|
||||
return workingApk?.androidManifest?.packageName
|
||||
}
|
||||
|
||||
fun PatcherContext.appVersion(): String? {
|
||||
return workingApk?.androidManifest?.versionName
|
||||
?: workingApk?.androidManifest?.versionCode?.toString()
|
||||
}
|
||||
|
||||
fun PatcherContext.appName(): String? {
|
||||
return workingApk?.androidManifest?.applicationLabelString
|
||||
?: workingApk
|
||||
?.tableBlock
|
||||
?.getEntries(workingApk?.androidManifest?.applicationLabelReference ?: 0)
|
||||
?.asSequence()
|
||||
?.firstOrNull()
|
||||
?.valueAsString
|
||||
}
|
||||
|
||||
fun PatcherContext.appIcon(): File? {
|
||||
val iconId = workingApk?.androidManifest?.iconResourceId?.takeIf { it != 0 }
|
||||
?: workingApk?.androidManifest?.roundIconResourceId?.takeIf { it != 0 }
|
||||
?: return null
|
||||
|
||||
val result = workingApk?.listResFiles(iconId, null)
|
||||
?: return null
|
||||
|
||||
// TODO layered adaptive icons support
|
||||
|
||||
arrayOf(
|
||||
"-xxxhdpi",
|
||||
"-xxhdpi",
|
||||
"-xhdpi",
|
||||
"-hdpi",
|
||||
"-mdpi",
|
||||
"-ldpi",
|
||||
"-anydpi",
|
||||
""
|
||||
).forEach { dpi ->
|
||||
arrayOf(".webp", ".png").forEach { ext ->
|
||||
result.firstOrNull { it.filePath.contains(dpi) && it.filePath.endsWith(ext) }?.let {
|
||||
workingDir.resolve("root").resolve(it.filePath).run {
|
||||
if (exists()) {
|
||||
return this
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package moe.crx.ovrport.patch
|
||||
|
||||
import com.android.apksig.ApkSigner.SignerConfig
|
||||
import com.android.apksig.KeyConfig
|
||||
import org.bouncycastle.asn1.x500.X500Name
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
|
||||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.math.BigInteger
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
import java.security.PrivateKey
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.Certificate
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
object SignatureStore {
|
||||
private const val KS_PASSWORD = "password"
|
||||
private const val KS_ALIAS = "key"
|
||||
|
||||
private fun readSignature(file: File): SignerConfig {
|
||||
val ks = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||
|
||||
FileInputStream(file).use { stream ->
|
||||
ks.load(stream, KS_PASSWORD.toCharArray())
|
||||
}
|
||||
|
||||
val alias = ks.aliases().nextElement()
|
||||
|
||||
val key = ks.getKey(alias, KS_PASSWORD.toCharArray()) as PrivateKey
|
||||
val cert = ks.getCertificate(alias) as X509Certificate
|
||||
|
||||
return SignerConfig.Builder(cert.issuerX500Principal.name, KeyConfig.Jca(key), listOf(cert)).build()
|
||||
}
|
||||
|
||||
private fun generateCertificate(keyPair: KeyPair): Certificate {
|
||||
val now = System.currentTimeMillis()
|
||||
val startDate = Date(now)
|
||||
val expireDate = Calendar.getInstance().run {
|
||||
time = startDate
|
||||
add(Calendar.YEAR, 25)
|
||||
time
|
||||
}
|
||||
val dnName = UUID
|
||||
.randomUUID()
|
||||
.toString()
|
||||
.replace("-", "")
|
||||
.prependIndent("CN=")
|
||||
.let { X500Name(it) }
|
||||
val serial = BigInteger(64, SecureRandom())
|
||||
|
||||
val signer = JcaContentSignerBuilder("SHA256withRSA").setProvider(BouncyCastleProvider()).build(keyPair.private)
|
||||
|
||||
return JcaX509v3CertificateBuilder(dnName, serial, startDate, expireDate, dnName, keyPair.public).build(signer).let {
|
||||
JcaX509CertificateConverter().setProvider(BouncyCastleProvider()).getCertificate(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSignature(file: File) {
|
||||
val ks = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||
ks.load(null, KS_PASSWORD.toCharArray())
|
||||
|
||||
val keyPair = KeyPairGenerator
|
||||
.getInstance("RSA", BouncyCastleProvider())
|
||||
.apply { initialize(2048) }
|
||||
.generateKeyPair()
|
||||
val cert = generateCertificate(keyPair)
|
||||
|
||||
ks.setKeyEntry(KS_ALIAS, keyPair.private, KS_PASSWORD.toCharArray(), arrayOf(cert))
|
||||
|
||||
FileOutputStream(file).use { stream ->
|
||||
ks.store(stream, KS_PASSWORD.toCharArray())
|
||||
}
|
||||
}
|
||||
|
||||
fun getSignature(file: File): SignerConfig {
|
||||
if (!file.exists()) {
|
||||
createSignature(file)
|
||||
}
|
||||
|
||||
return readSignature(file)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package moe.crx.ovrport.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import moe.crx.ovrport.AndroidPlatform
|
||||
import moe.crx.ovrport.getCurrentVersion
|
||||
import moe.crx.ovrport.patch.Patch
|
||||
import moe.crx.ovrport.getPlatform
|
||||
import moe.crx.ovrport.model.GithubRelease
|
||||
import moe.crx.ovrport.patch.Constants
|
||||
import moe.crx.ovrport.ui.theme.OverportTheme
|
||||
import moe.crx.ovrport.utils.HttpUtil
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import overportapp.composeapp.generated.resources.Res
|
||||
import overportapp.composeapp.generated.resources.overport
|
||||
import overportapp.composeapp.generated.resources.update_available
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppContent(
|
||||
viewModel: MainViewModel,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
onOpen: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onConfirm: (List<Patch>) -> Unit
|
||||
) {
|
||||
OverportTheme(darkTheme = true) {
|
||||
var versionToUpdate by remember { mutableStateOf<GithubRelease?>(null) }
|
||||
val urlHandler = LocalUriHandler.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
versionToUpdate = viewModel.versionToUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(
|
||||
if (getPlatform() is AndroidPlatform) {
|
||||
Modifier.clip(RoundedCornerShape(32.dp))
|
||||
} else Modifier
|
||||
),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
painterResource(Res.drawable.overport),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(112.dp, 24.dp)
|
||||
)
|
||||
Text(
|
||||
Constants.OVRPORT_VERSION,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
AnimatedVisibility(
|
||||
versionToUpdate?.name != null && versionToUpdate?.name != getCurrentVersion(),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clip(
|
||||
RoundedCornerShape(16.dp)
|
||||
).clickable {
|
||||
versionToUpdate?.htmlUrl?.let { urlHandler.openUri(it) }
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.CloudDownload,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.update_available),
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.padding(0.dp, 8.dp, 8.dp, 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
),
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
val inProgress = viewModel.working
|
||||
val isPatcherVisible = viewModel.isApkLoaded() && !inProgress
|
||||
|
||||
AnimatedVisibility(
|
||||
!isPatcherVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
StartingCard(innerPadding, inProgress) {
|
||||
onOpen()
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
isPatcherVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
ApplicationInfoCard(
|
||||
innerPadding,
|
||||
viewModel.currentAppName(),
|
||||
viewModel.currentAppPackage(),
|
||||
viewModel.currentAppVersion(),
|
||||
viewModel.currentAppIcon(),
|
||||
onCancel = {
|
||||
onCancel()
|
||||
},
|
||||
onConfirm = {
|
||||
onConfirm(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package moe.crx.ovrport.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.unit.dp
|
||||
import moe.crx.ovrport.patch.Patch
|
||||
import moe.crx.ovrport.patch.PatchStore
|
||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.material.icons.filled.Widgets
|
||||
import androidx.compose.material3.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import overportapp.composeapp.generated.resources.Res
|
||||
import overportapp.composeapp.generated.resources.unknown_package
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@Preview
|
||||
fun ApplicationInfoCard(
|
||||
innerPadding: PaddingValues = PaddingValues(16.dp),
|
||||
applicationName: String? = "Application",
|
||||
applicationPackage: String? = "com.company.application",
|
||||
applicationVersion: String? = "1.0.0",
|
||||
applicationIcon: ImageBitmap? = null,
|
||||
onCancel: () -> Unit = {},
|
||||
onConfirm: (List<Patch>) -> Unit = {}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (applicationIcon == null) {
|
||||
Icon(
|
||||
Icons.Default.Widgets,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
applicationIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp))
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.height(48.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text("$applicationName ($applicationVersion)")
|
||||
Text(
|
||||
applicationPackage ?: stringResource(Res.string.unknown_package),
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val patches = remember { mutableStateListOf(*PatchStore.PATCHES.toTypedArray()) }
|
||||
|
||||
fun togglePatch(patch: Patch) {
|
||||
val checked = patches.contains(patch)
|
||||
|
||||
if (checked) {
|
||||
patches.remove(patch)
|
||||
} else {
|
||||
patches.add(patch)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
items(PatchStore.PATCHES) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(it.name)
|
||||
},
|
||||
supportingContent = {
|
||||
it.desc?.let {
|
||||
Text(it)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Checkbox(patches.contains(it), { _ ->
|
||||
togglePatch(it)
|
||||
})
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
togglePatch(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onCancel
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
onConfirm(patches)
|
||||
}
|
||||
) {
|
||||
Text("Confirm")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package moe.crx.ovrport.ui
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import moe.crx.ovrport.model.GithubRelease
|
||||
import moe.crx.ovrport.patch.*
|
||||
import moe.crx.ovrport.utils.HttpUtil.download
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
class MainViewModel : ViewModel() {
|
||||
|
||||
private var patcherState by mutableStateOf<PatcherContext?>(null)
|
||||
var working by mutableStateOf(false)
|
||||
private set
|
||||
private var fileName: String = "game"
|
||||
private var imageConverter: ((File) -> ImageBitmap)? = null
|
||||
|
||||
private suspend fun usePatcher(block: PatcherContext.() -> Unit) {
|
||||
working = true
|
||||
withContext(Dispatchers.IO) {
|
||||
patcherState?.block()
|
||||
}
|
||||
working = false
|
||||
}
|
||||
|
||||
suspend fun import(dataDirectory: File, name: String, inputStream: InputStream) {
|
||||
fileName = name
|
||||
patcherState = PatcherContext(dataDirectory)
|
||||
usePatcher { prepare(inputStream) }
|
||||
}
|
||||
|
||||
fun patchedName(): String {
|
||||
return fileName.substringBeforeLast('.').let { "$it.output.apk" }
|
||||
}
|
||||
|
||||
suspend fun process(patches: List<Patch>) {
|
||||
usePatcher { patch(patches) }
|
||||
}
|
||||
|
||||
suspend fun export(outputStream: OutputStream) {
|
||||
usePatcher { export(outputStream) }
|
||||
}
|
||||
|
||||
suspend fun cancel() {
|
||||
usePatcher { cleanup() }
|
||||
fileName = "game"
|
||||
patcherState = null
|
||||
}
|
||||
|
||||
fun currentAppName(): String? {
|
||||
return patcherState?.appName()
|
||||
}
|
||||
|
||||
fun currentAppPackage(): String? {
|
||||
return patcherState?.appPackage()
|
||||
}
|
||||
|
||||
fun currentAppVersion(): String? {
|
||||
return patcherState?.appVersion()
|
||||
}
|
||||
|
||||
fun setAppIconConverter(converter: (File) -> ImageBitmap) {
|
||||
imageConverter = converter
|
||||
}
|
||||
|
||||
fun currentAppIcon(): ImageBitmap? {
|
||||
return patcherState?.appIcon()?.let { file ->
|
||||
imageConverter?.let {
|
||||
it(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isApkLoaded(): Boolean {
|
||||
return patcherState?.workingApk != null
|
||||
}
|
||||
|
||||
fun versionToUpdate(): GithubRelease? {
|
||||
return runCatching {
|
||||
json
|
||||
.decodeFromString<List<GithubRelease>>(String(download(Constants.RELEASES_URL)))
|
||||
.reduceOrNull { left, right -> if (left.publishedAt > right.publishedAt) left else right }
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package moe.crx.ovrport.ui
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddCircle
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import moe.crx.ovrport.AndroidPlatform
|
||||
import moe.crx.ovrport.getPlatform
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||
import overportapp.composeapp.generated.resources.Res
|
||||
import overportapp.composeapp.generated.resources.decoding_apk
|
||||
import overportapp.composeapp.generated.resources.or_drag_and_drop_it
|
||||
import overportapp.composeapp.generated.resources.select_apk_to_begin
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun StartingCard(
|
||||
innerPadding: PaddingValues = PaddingValues(16.dp),
|
||||
showLoading: Boolean = false,
|
||||
onCardClick: () -> Unit = {}
|
||||
) {
|
||||
val strokeColor = LocalContentColor.current
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
.drawBehind {
|
||||
drawRoundRect(
|
||||
color = strokeColor,
|
||||
style = Stroke(
|
||||
width = 4.dp.toPx(),
|
||||
pathEffect = PathEffect.dashPathEffect(
|
||||
floatArrayOf(
|
||||
16.dp.toPx(),
|
||||
16.dp.toPx()
|
||||
), 16.dp.toPx()
|
||||
)
|
||||
),
|
||||
cornerRadius = CornerRadius(32.dp.toPx())
|
||||
)
|
||||
}
|
||||
.clip(RoundedCornerShape(32.dp))
|
||||
.clickable(!showLoading, onClick = onCardClick),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (!showLoading) {
|
||||
Icon(
|
||||
Icons.Default.Download,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.width(64.dp)
|
||||
.height(64.dp)
|
||||
)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = stringResource(Res.string.select_apk_to_begin)
|
||||
)
|
||||
if (getPlatform() !is AndroidPlatform) {
|
||||
Text(
|
||||
text = stringResource(Res.string.or_drag_and_drop_it)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
BreathingIcon(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.rotate(30f)
|
||||
.width(96.dp)
|
||||
.height(96.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.decoding_apk)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BreathingIcon(modifier: Modifier, duration: Int = 1500) {
|
||||
var isFaded by remember { mutableStateOf(false) }
|
||||
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (isFaded) 0.2f else 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = duration, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = ""
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
isFaded = !isFaded
|
||||
delay(duration.toLong())
|
||||
}
|
||||
}
|
||||
|
||||
Icon(
|
||||
Icons.Default.Bolt,
|
||||
contentDescription = null,
|
||||
modifier = modifier.alpha(alpha)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package moe.crx.ovrport.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
@@ -0,0 +1,46 @@
|
||||
package moe.crx.ovrport.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80,
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40,
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun OverportTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package moe.crx.ovrport.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package moe.crx.ovrport.utils
|
||||
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
|
||||
object HttpUtil {
|
||||
fun download(url: String): ByteArray {
|
||||
val uri = URI(url).toURL().openConnection() as HttpURLConnection
|
||||
|
||||
val bytes = uri.errorStream?.use {
|
||||
it.readBytes()
|
||||
} ?: uri.inputStream.use {
|
||||
it.readBytes()
|
||||
}
|
||||
|
||||
uri.disconnect()
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package moe.crx.ovrport.utils
|
||||
|
||||
import com.reandroid.json.JSONArray
|
||||
import com.reandroid.json.JSONObject
|
||||
|
||||
inline fun <reified T> JSONObject?.elem(name: String): T? {
|
||||
return this?.opt(name).takeIf { it is T } as T?
|
||||
}
|
||||
|
||||
inline fun <reified T> JSONArray?.elem(index: Int): T? {
|
||||
return this?.opt(index).takeIf { it is T } as T?
|
||||
}
|
||||
|
||||
inline fun <reified T> JSONObject?.take(name: String, block: T?.() -> T? = { this }): JSONObject? {
|
||||
this?.put(name, block(elem<T>(name)))
|
||||
return this
|
||||
}
|
||||
|
||||
inline fun <reified T> JSONArray?.take(index: Int, block: T?.() -> T? = { this }): JSONArray? {
|
||||
this?.put(index, block(elem<T>(index)))
|
||||
return this
|
||||
}
|
||||
|
||||
inline fun <reified T> JSONObject?.takeEach(
|
||||
condition: T?.() -> Boolean = { true },
|
||||
block: T?.() -> T? = { this }
|
||||
): JSONObject? {
|
||||
this?.keys()?.asSequence()?.toList()
|
||||
?.forEach { i ->
|
||||
elem<T>(i).let {
|
||||
if (condition(it)) {
|
||||
block(it)?.let { value -> put(i, value) }
|
||||
?: remove(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
inline fun <reified T> JSONArray?.takeEach(
|
||||
condition: T?.() -> Boolean = { true },
|
||||
block: T?.() -> T? = { this }
|
||||
): JSONArray? {
|
||||
this?.length()?.let { (it - 1).downTo(0) }
|
||||
?.forEach { i ->
|
||||
elem<T>(i).let {
|
||||
if (condition(it)) {
|
||||
block(it)?.let { value -> put(i, value) }
|
||||
?: remove(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
inline fun <reified T> JSONObject?.elemEach(condition: T?.() -> Boolean = { true }): List<T> {
|
||||
return this?.keys()?.asSequence()?.toList()
|
||||
?.mapNotNull { i -> elem<T>(i) }
|
||||
?.filter { condition(it) }
|
||||
?.toList()
|
||||
?: listOf()
|
||||
}
|
||||
|
||||
inline fun <reified T> JSONArray?.elemEach(condition: T?.() -> Boolean = { true }): List<T> {
|
||||
return this?.length()?.let { (it - 1).downTo(0) }
|
||||
?.mapNotNull { i -> elem<T>(i) }
|
||||
?.filter { condition(it) }
|
||||
?.toList()
|
||||
?: listOf()
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package moe.crx.ovrport.utils
|
||||
|
||||
import com.reandroid.json.JSONArray
|
||||
import com.reandroid.json.JSONObject
|
||||
|
||||
fun JSONObject?.named(name: String?): Boolean {
|
||||
return elem<String>("name") == name
|
||||
}
|
||||
|
||||
fun JSONObject?.takeNodes(block: JSONArray?.() -> JSONArray? = { this }): JSONObject? {
|
||||
return take("nodes", block)
|
||||
}
|
||||
|
||||
fun JSONObject?.takeAttributes(block: JSONArray?.() -> JSONArray? = { this }): JSONObject? {
|
||||
return take("attributes", block)
|
||||
}
|
||||
|
||||
fun JSONObject?.takeNodesEach(
|
||||
condition: JSONObject?.() -> Boolean = { true },
|
||||
block: JSONObject?.() -> JSONObject? = { this }
|
||||
): JSONObject? {
|
||||
return takeNodes {
|
||||
takeEach(condition, block)
|
||||
}
|
||||
}
|
||||
|
||||
fun JSONObject?.takeAttributesEach(
|
||||
condition: JSONObject?.() -> Boolean = { true },
|
||||
block: JSONObject?.() -> JSONObject? = { this }
|
||||
): JSONObject? {
|
||||
return takeAttributes {
|
||||
takeEach(condition, block)
|
||||
}
|
||||
}
|
||||
|
||||
fun JSONObject?.attributeName(): String? {
|
||||
return elem<JSONArray>("attributes")
|
||||
.elemEach<JSONObject> { named("name") }
|
||||
.firstOrNull()
|
||||
.elem<String>("data")
|
||||
}
|
||||
|
||||
fun createMetadata(name: String, value: String): JSONObject {
|
||||
return JSONObject()
|
||||
.put("node_type", "element")
|
||||
.put("name", "meta-data")
|
||||
.put(
|
||||
"attributes",
|
||||
JSONArray()
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("name", "name")
|
||||
.put("id", 16842755)
|
||||
.put("uri", "http://schemas.android.com/apk/res/android")
|
||||
.put("prefix", "android")
|
||||
.put("value_type", "STRING")
|
||||
.put("data", name)
|
||||
)
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("name", "value")
|
||||
.put("id", 16842788)
|
||||
.put("uri", "http://schemas.android.com/apk/res/android")
|
||||
.put("prefix", "android")
|
||||
.put("value_type", "STRING")
|
||||
.put("data", value)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createCategory(value: String): JSONObject {
|
||||
return JSONObject()
|
||||
.put("node_type", "element")
|
||||
.put("name", "category")
|
||||
.put(
|
||||
"attributes",
|
||||
JSONArray()
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("name", "name")
|
||||
.put("id", 16842755)
|
||||
.put("uri", "http://schemas.android.com/apk/res/android")
|
||||
.put("prefix", "android")
|
||||
.put("value_type", "STRING")
|
||||
.put("data", value)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package moe.crx.ovrport
|
||||
|
||||
actual fun getPlatform(): Platform = JVMPlatform("Java ${System.getProperty("java.version")}")
|
||||
@@ -0,0 +1,157 @@
|
||||
package moe.crx.ovrport
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.draganddrop.dragAndDropTarget
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||
import androidx.compose.ui.draganddrop.DragAndDropTarget
|
||||
import androidx.compose.ui.draganddrop.awtTransferable
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import moe.crx.ovrport.ui.AppContent
|
||||
import org.jetbrains.skia.Image
|
||||
import java.io.FileInputStream
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.crx.ovrport.patch.CantDecodeApkException
|
||||
import moe.crx.ovrport.patch.CantUpdateOverportException
|
||||
import moe.crx.ovrport.ui.MainViewModel
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import overportapp.composeapp.generated.resources.*
|
||||
import overportapp.composeapp.generated.resources.Res
|
||||
import overportapp.composeapp.generated.resources.apk_file_exported
|
||||
import overportapp.composeapp.generated.resources.cant_read_apk
|
||||
import overportapp.composeapp.generated.resources.window_icon
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
|
||||
fun main() = application {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "overport",
|
||||
icon = painterResource(Res.drawable.window_icon),
|
||||
) {
|
||||
val viewModel: MainViewModel = viewModel { MainViewModel() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// TODO Deal with this copy-paste
|
||||
val cantReadApkMessage = stringResource(Res.string.cant_read_apk)
|
||||
val apkFileExportedMessage = stringResource(Res.string.apk_file_exported)
|
||||
val cantUpdateOverportMessage = stringResource(Res.string.cant_update_overport)
|
||||
val unknownErrorMessage = stringResource(Res.string.unknown_error)
|
||||
|
||||
suspend fun openFile(file: File) {
|
||||
if (!file.exists() || file.extension != "apk") {
|
||||
return
|
||||
}
|
||||
|
||||
val dataDir = File(System.getProperty("user.home")).resolve("overport/cache")
|
||||
dataDir.mkdirs()
|
||||
|
||||
FileInputStream(file).use { s ->
|
||||
try {
|
||||
viewModel.import(dataDir, file.name, s)
|
||||
} catch (_: CantDecodeApkException) {
|
||||
viewModel.cancel()
|
||||
snackbarHostState.showSnackbar(cantReadApkMessage)
|
||||
} catch (_: CantUpdateOverportException) {
|
||||
viewModel.cancel()
|
||||
snackbarHostState.showSnackbar(cantUpdateOverportMessage)
|
||||
} catch (_: Throwable) {
|
||||
viewModel.cancel()
|
||||
snackbarHostState.showSnackbar(unknownErrorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val dragAndDropTarget = remember {
|
||||
object: DragAndDropTarget {
|
||||
override fun onDrop(event: DragAndDropEvent): Boolean {
|
||||
val value: List<File>? = event.awtTransferable
|
||||
.takeIf { it.isDataFlavorSupported(DataFlavor.javaFileListFlavor) }
|
||||
?.getTransferData(DataFlavor.javaFileListFlavor)
|
||||
?.let { it as? List<*> }
|
||||
?.filterIsInstance<File>()
|
||||
|
||||
if (value?.size == 1) {
|
||||
coroutineScope.launch {
|
||||
openFile(value.first())
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().dragAndDropTarget(
|
||||
shouldStartDragAndDrop = { !viewModel.working && !viewModel.isApkLoaded() },
|
||||
target = dragAndDropTarget
|
||||
)) {
|
||||
AppContent(
|
||||
viewModel = viewModel.apply {
|
||||
setAppIconConverter {
|
||||
FileInputStream(it).use { stream ->
|
||||
Image.makeFromEncoded(stream.readAllBytes()).toComposeImageBitmap()
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHostState = snackbarHostState,
|
||||
onOpen = {
|
||||
val fileDialog = FileDialog(Frame(), "Select a file", FileDialog.LOAD)
|
||||
fileDialog.isVisible = true
|
||||
val file = fileDialog.file?.let { File(fileDialog.directory).resolve(it) }
|
||||
|
||||
coroutineScope.launch {
|
||||
if (file != null) {
|
||||
openFile(file)
|
||||
}
|
||||
}
|
||||
},
|
||||
onConfirm = {
|
||||
val fileDialog = FileDialog(Frame(), "Select a file", FileDialog.SAVE)
|
||||
|
||||
coroutineScope.run {
|
||||
fileDialog.file = viewModel.patchedName()
|
||||
}
|
||||
|
||||
fileDialog.isVisible = true
|
||||
val file = fileDialog.file?.let { File(fileDialog.directory).resolve(it) }
|
||||
|
||||
coroutineScope.launch {
|
||||
if (file != null) {
|
||||
viewModel.process(it)
|
||||
FileOutputStream(file).use { s ->
|
||||
viewModel.export(s)
|
||||
launch {
|
||||
snackbarHostState.showSnackbar(apkFileExportedMessage)
|
||||
}
|
||||
}
|
||||
viewModel.cancel()
|
||||
}
|
||||
}
|
||||
},
|
||||
onCancel = {
|
||||
coroutineScope.launch {
|
||||
viewModel.cancel()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user