diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2656af2..86ad284 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,90 +12,146 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - cache: gradle - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Build Android APK - run: ./gradlew :composeApp:assembleRelease - - - name: Upload Android APK - uses: actions/upload-artifact@v4 - with: - name: android-apk - path: composeApp/build/outputs/apk/release/*.apk - retention-days: 30 + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build Android APK + run: ./gradlew :composeApp:assembleRelease + + - name: Upload Android APK + uses: actions/upload-artifact@v4 + with: + name: android-apk + path: composeApp/build/outputs/apk/release/*.apk + retention-days: 30 + + build-cli: + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Build CLI package + run: ./gradlew :overportcli:shadowJar + shell: bash + + - name: Upload CLI artifacts + uses: actions/upload-artifact@v4 + with: + name: cli-jar + path: overportcli/build/libs/* + retention-days: 30 build-desktop-windows: runs-on: windows-latest steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - cache: gradle - - - name: Build Desktop package - run: ./gradlew :composeApp:packageReleaseDistributionForCurrentOS - shell: bash - - - name: Upload Desktop artifacts - uses: actions/upload-artifact@v4 - with: - name: desktop-windows - path: composeApp/build/compose/binaries/main-release/**/* - retention-days: 30 + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Build Desktop package + run: ./gradlew :composeApp:packageReleaseDistributionForCurrentOS + shell: bash + + - name: Upload Desktop artifacts + uses: actions/upload-artifact@v4 + with: + name: desktop-windows + path: composeApp/build/compose/binaries/main-release/**/* + retention-days: 30 build-desktop-linux: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - cache: gradle - - - name: Grant execute permission for gradlew - run: chmod +x gradlew + - name: Checkout code + uses: actions/checkout@v4 - - name: Build Desktop package - run: ./gradlew :composeApp:packageReleaseDistributionForCurrentOS - shell: bash - - - name: Build JAR package - run: ./gradlew :composeApp:packageUberJarForCurrentOS - shell: bash + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle - - name: Upload Desktop artifacts - uses: actions/upload-artifact@v4 - with: - name: desktop-linux - path: composeApp/build/compose/binaries/main-release/**/* - retention-days: 30 + - name: Grant execute permission for gradlew + run: chmod +x gradlew - - name: Upload JAR artifacts - uses: actions/upload-artifact@v4 - with: - name: desktop-jar - path: composeApp/build/compose/jars/* - retention-days: 30 + - name: Build Desktop package + run: ./gradlew :composeApp:packageReleaseDistributionForCurrentOS + shell: bash + - name: Build JAR package + run: ./gradlew :composeApp:packageUberJarForCurrentOS + shell: bash + + - name: Upload Desktop artifacts + uses: actions/upload-artifact@v4 + with: + name: desktop-linux + path: composeApp/build/compose/binaries/main-release/**/* + retention-days: 30 + + - name: Upload JAR artifacts + uses: actions/upload-artifact@v4 + with: + name: desktop-jar + path: composeApp/build/compose/jars/* + retention-days: 30 + + build-desktop-macos: + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build Desktop package + run: ./gradlew :composeApp:packageReleaseDistributionForCurrentOS + shell: bash + + - name: Build JAR package + run: ./gradlew :composeApp:packageUberJarForCurrentOS + shell: bash + + - name: Upload Desktop artifacts + uses: actions/upload-artifact@v4 + with: + name: desktop-macos + path: composeApp/build/compose/binaries/main-release/**/* + retention-days: 30 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 014cf27..a2d2ff5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ captures *.apk composeApp/release/output-metadata.json *.salive +workspace +ignore diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 0ca4067..9dc7013 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.composeHotReload) } kotlin { @@ -17,17 +18,18 @@ kotlin { 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(project(":overportcli")) implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) @@ -38,10 +40,7 @@ kotlin { 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) @@ -51,11 +50,11 @@ kotlin { } android { - namespace = "moe.crx.ovrport" + namespace = "moe.crx.overport.app" compileSdk = libs.versions.android.compileSdk.get().toInt() defaultConfig { - applicationId = "moe.crx.ovrport" + applicationId = "moe.crx.overport.app" 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() } @@ -89,18 +88,19 @@ dependencies { compose.desktop { application { - mainClass = "moe.crx.ovrport.MainKt" + mainClass = "moe.crx.overport.app.ComposeMainKt" buildTypes { release { proguard { configurationFiles.from("proguard-rules.pro") + isEnabled = false } } } nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Exe, TargetFormat.AppImage) + targetFormats(TargetFormat.Dmg, TargetFormat.Exe, TargetFormat.Deb, TargetFormat.Rpm) packageName = "overport" packageVersion = appVersion @@ -120,21 +120,3 @@ compose.desktop { } } } - -tasks.withType { - // Need for valid signature check - exclude( - "META-INF/*.SF", - "META-INF/*.DSA", - "META-INF/*.RSA", - "META-INF/*.EC" - ) - - exclude( - "META-INF/INDEX.LIST", - "META-INF/DEPENDENCIES", - "META-INF/LICENSE*", - "META-INF/NOTICE*" - ) -} - diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 63a10f8..1a571e4 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -1,25 +1,24 @@ + xmlns:tools="http://schemas.android.com/tools"> - + - + android:label="@string/app_name" + android:supportsRtl="true" + android:extractNativeLibs="true" + android:theme="@style/Theme.OverportSplash" + tools:targetApi="31"> + - - + + diff --git a/composeApp/src/androidMain/kotlin/moe/crx/overport/app/BitmapIconResizer.kt b/composeApp/src/androidMain/kotlin/moe/crx/overport/app/BitmapIconResizer.kt new file mode 100644 index 0000000..17efac5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/moe/crx/overport/app/BitmapIconResizer.kt @@ -0,0 +1,17 @@ +package moe.crx.overport.app + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import moe.crx.overport.patches.IconResizer +import java.io.ByteArrayOutputStream + +object BitmapIconResizer : IconResizer { + + override fun resize(bytes: ByteArray, width: Int, height: Int): ByteArray { + val baos = ByteArrayOutputStream() + BitmapFactory.decodeByteArray(bytes, 0, bytes.size).apply { + reconfigure(width, height, Bitmap.Config.ARGB_8888) + }.compress(Bitmap.CompressFormat.PNG, 100, baos) + return baos.toByteArray() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/moe/crx/overport/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/moe/crx/overport/app/MainActivity.kt new file mode 100644 index 0000000..d3264cb --- /dev/null +++ b/composeApp/src/androidMain/kotlin/moe/crx/overport/app/MainActivity.kt @@ -0,0 +1,90 @@ +package moe.crx.overport.app + +import android.annotation.SuppressLint +import android.graphics.Color +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import moe.crx.overport.app.composables.AppContent +import moe.crx.overport.app.model.MainViewModel +import java.io.InputStream +import java.io.OutputStream + +class MainActivity : ComponentActivity() { + companion object { + const val APK_MIMETYPE = "application/vnd.android.package-archive" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setTheme(R.style.Theme_Overport) + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + ) + setContent { + ActivityContent(this) + } + } + + @SuppressLint("Recycle") + @Composable + private fun ActivityContent(activity: MainActivity) { + val viewModel: MainViewModel = viewModel { MainViewModel(activity.cacheDir, BitmapIconResizer) } + val openFlow = remember { MutableSharedFlow?>() } + val saveFlow = remember { MutableSharedFlow() } + val scope = rememberCoroutineScope() + + val openApkFile = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + if (uri == null) { + scope.launch { + openFlow.emit(null) + } + return@rememberLauncherForActivityResult + } + + val stream = activity.contentResolver.openInputStream(uri) ?: return@rememberLauncherForActivityResult + val fileName = uri.lastPathSegment?.substringAfter(':') ?: "application.apk" + + scope.launch { + openFlow.emit(fileName to stream) + } + } + + val saveApkFile = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument(APK_MIMETYPE) + ) { uri -> + if (uri == null) { + scope.launch { + openFlow.emit(null) + } + return@rememberLauncherForActivityResult + } + + val stream = activity.contentResolver.openOutputStream(uri) ?: return@rememberLauncherForActivityResult + + scope.launch { + saveFlow.emit(stream) + } + } + + AppContent( + viewModel = viewModel, + openFile = { openApkFile.launch(arrayOf(APK_MIMETYPE)) }, + saveFile = { name -> saveApkFile.launch(name) }, + openFlow = openFlow, + saveFlow = saveFlow, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/moe/crx/overport/app/Platform.android.kt b/composeApp/src/androidMain/kotlin/moe/crx/overport/app/Platform.android.kt new file mode 100644 index 0000000..a7bc259 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/moe/crx/overport/app/Platform.android.kt @@ -0,0 +1,21 @@ +package moe.crx.overport.app + +import android.graphics.BitmapFactory +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +actual fun getPlatform(): Platform = AndroidPlatform("Android ${Build.VERSION.SDK_INT}") + +actual fun ByteArray.decodeBitmap(): ImageBitmap { + return BitmapFactory.decodeByteArray(this, 0, size).asImageBitmap() +} + +@Composable +actual fun screenWidthDp(): Dp { + return LocalConfiguration.current.screenWidthDp.dp +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/moe/crx/ovrport/MainActivity.kt b/composeApp/src/androidMain/kotlin/moe/crx/ovrport/MainActivity.kt deleted file mode 100644 index 1449ba2..0000000 --- a/composeApp/src/androidMain/kotlin/moe/crx/ovrport/MainActivity.kt +++ /dev/null @@ -1,119 +0,0 @@ -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() - } - }, - ) -} - diff --git a/composeApp/src/androidMain/kotlin/moe/crx/ovrport/Platform.android.kt b/composeApp/src/androidMain/kotlin/moe/crx/ovrport/Platform.android.kt deleted file mode 100644 index 721ff1e..0000000 --- a/composeApp/src/androidMain/kotlin/moe/crx/ovrport/Platform.android.kt +++ /dev/null @@ -1,5 +0,0 @@ -package moe.crx.ovrport - -import android.os.Build - -actual fun getPlatform(): Platform = AndroidPlatform("Android ${Build.VERSION.SDK_INT}") \ No newline at end of file diff --git a/composeApp/src/androidMain/res/values/themes.xml b/composeApp/src/androidMain/res/values/themes.xml index 7b7f41f..62fc74e 100644 --- a/composeApp/src/androidMain/res/values/themes.xml +++ b/composeApp/src/androidMain/res/values/themes.xml @@ -1,6 +1,8 @@ - + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 173506d..379c0d2 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1,4 +1,26 @@ + Select a file + Use game icon for replacement + Use game cover for replacement + Preparing libraries… + Decompiling APK… + Applying patches… + Compiling APK… + Cleaning up… + Patcher + Select version + You have no versions installed and overport release index is not available. + Check your internet connection. + + experimental + latest + custom + Installed + Installed but incompatible + Not installed + Incompatible with app version + Confirm + Cancel Select an APK to begin (or drag and drop it) Processing APK… @@ -13,4 +35,25 @@ Has problems Not working Loading… + Not recommended, use at your own risk + Copy ovrport libraries + Copy OVRPlugin if VrApi is present + Fix minimal Android SDK + Generate ovrport config + Remove localized app names + Clean up Frida leftovers + Patch Oculus detection for Unity + Patch Oculus detection for Unreal + Pico/YVR/Quest metadata + Fix launcher icon entry + Remove uses-library + Fix Unreal Engine 4 crash + Patch Meta XR Audio + Mark application as debuggable + Mark application to allow backup + Replace application label and icon + Remove Unreal's ForceQuit + Force enable passthrough + Disable application space warp if used + Disable controller tracking offset \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/overport/app/Platform.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/Platform.kt new file mode 100644 index 0000000..0ab649a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/Platform.kt @@ -0,0 +1,20 @@ +package moe.crx.overport.app + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.unit.Dp + +interface Platform { + val name: String +} + +class JVMPlatform(override val name: String) : Platform + +class AndroidPlatform(override val name: String) : Platform + +expect fun getPlatform(): Platform + +expect fun ByteArray.decodeBitmap(): ImageBitmap + +@Composable +expect fun screenWidthDp(): Dp \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/AppContent.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/AppContent.kt new file mode 100644 index 0000000..9b82d53 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/AppContent.kt @@ -0,0 +1,112 @@ +package moe.crx.overport.app.composables + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.outlined.Build +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.withContext +import moe.crx.overport.app.model.GithubRelease +import moe.crx.overport.app.model.MainViewModel +import moe.crx.overport.app.theme.OverportTheme +import moe.crx.overport.versions.VersionManager +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.patcher_nav_item +import overportapp.composeapp.generated.resources.update_available +import java.io.InputStream +import java.io.OutputStream + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppContent( + viewModel: MainViewModel, + openFile: () -> Unit, + saveFile: (String) -> Unit, + openFlow: MutableSharedFlow?>, + saveFlow: MutableSharedFlow, +) { + OverportTheme(darkTheme = true) { + val snackbarHostState = remember { SnackbarHostState() } + + val patcherNavItem = + BottomNavItem(stringResource(Res.string.patcher_nav_item), Icons.Filled.Build, Icons.Outlined.Build) { + PatcherScreen(viewModel, snackbarHostState, openFile, saveFile, openFlow, saveFlow) + } + + var versionToUpdate by remember { mutableStateOf(null) } + var showAboutDialog by remember { mutableStateOf(false) } + val urlHandler = LocalUriHandler.current + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + versionToUpdate = viewModel.versionToUpdate() + } + } + + DynamicScaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painterResource(Res.drawable.overport), + contentDescription = null, + modifier = Modifier.size(112.dp, 24.dp) + ) + } + }, + actions = { + val isUpdateAvailable = + versionToUpdate?.name != null && versionToUpdate?.name != VersionManager.VERSION + FadeVisibility(isUpdateAvailable) { + FilledTonalButton( + modifier = Modifier.padding(8.dp, 0.dp), + onClick = { versionToUpdate?.htmlUrl?.let { urlHandler.openUri(it) } } + ) { + Icon( + Icons.Default.CloudDownload, + contentDescription = null, + ) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(Res.string.update_available), + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ), + ) + }, + navigationItems = listOf(patcherNavItem), + itemsEnabled = !viewModel.working && !viewModel.isApkLoaded() + ) + + if (showAboutDialog) { + AlertDialog( + onDismissRequest = { showAboutDialog = false }, + title = { Text("overport") }, + text = { Text("about overport") }, // TODO + confirmButton = { + TextButton(onClick = { showAboutDialog = false }) { + Text("Close") + } + }, + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/ApplicationInfo.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/ApplicationInfo.kt new file mode 100644 index 0000000..70f42ff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/ApplicationInfo.kt @@ -0,0 +1,103 @@ +package moe.crx.overport.app.composables + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Widgets +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import moe.crx.overport.app.decodeBitmap +import moe.crx.overport.app.util.ModifierUtil.rounded +import moe.crx.overport.config.getApplicationInfo +import org.jetbrains.compose.resources.stringResource +import overportapp.composeapp.generated.resources.Res +import overportapp.composeapp.generated.resources.unknown_package +import java.util.* + +@Composable +fun ApplicationInfo( + applicationName: String? = "Application", + applicationPackage: String? = "com.company.application", + applicationVersion: String? = "1.0.0", + applicationIcon: ImageBitmap? = null, +) { + var iconIsLoading by rememberSaveable(applicationPackage) { mutableStateOf(true) } + var onlineIcon by rememberSaveable(applicationPackage) { mutableStateOf(null) } + var onlineLabel by rememberSaveable(applicationPackage) { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + LaunchedEffect(applicationPackage) { + scope.launch { + withContext(Dispatchers.IO) { + runCatching { + val info = applicationPackage?.let { getApplicationInfo(it) } + onlineLabel = info?.displayName + + val image = info?.run { + val associated = images.associateBy { it.imageType } + associated["APP_IMG_ICON"] ?: associated["APP_IMG_COVER_SQUARE"] + } + + onlineIcon = image?.uri?.let { Base64.getDecoder().decode(it) }?.decodeBitmap() + } + iconIsLoading = false + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box { + FadeVisibility(!iconIsLoading) { + onlineIcon?.let { + Image( + it, + contentDescription = null, + modifier = Modifier.size(48.dp).rounded(8.dp) + ) + } ?: run { + if (applicationIcon == null) { + Icon( + Icons.Default.Widgets, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + } else { + Image( + applicationIcon, + contentDescription = null, + modifier = Modifier.size(48.dp).rounded(8.dp) + ) + } + } + } + FadeVisibility(iconIsLoading) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + } + } + Column( + modifier = Modifier.height(48.dp), + verticalArrangement = Arrangement.Center + ) { + AnimatedContent(onlineLabel ?: applicationName) { + Text("$it ($applicationVersion)") + } + Text( + applicationPackage ?: stringResource(Res.string.unknown_package), + color = MaterialTheme.colorScheme.secondary + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/ApplicationInfoCard.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/ApplicationInfoCard.kt new file mode 100644 index 0000000..e581a03 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/ApplicationInfoCard.kt @@ -0,0 +1,140 @@ +package moe.crx.overport.app.composables + +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.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.unit.dp +import moe.crx.overport.app.util.PatchLocalizer.localizedPatch +import moe.crx.overport.patches.PATCH_REPLACE_ICON_LABEL +import moe.crx.overport.patching.Patch +import moe.crx.overport.patching.PatchStore +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview +import overportapp.composeapp.generated.resources.* + +@OptIn(ExperimentalLayoutApi::class) +@Composable +@Preview +fun ApplicationInfoCard( + applicationName: String? = "Application", + applicationPackage: String? = "", + applicationVersion: String? = "1.0.0", + applicationIcon: ImageBitmap? = null, + onCancel: () -> Unit = {}, + onConfirm: (Map>) -> Unit = {} +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FlowRow( + modifier = Modifier.fillMaxWidth().padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ApplicationInfo(applicationName, applicationPackage, applicationVersion, applicationIcon) + } + + val enabledPatches = remember { + mutableStateMapOf( + *PatchStore.recommended().map { it.name to listOf() }.toTypedArray() + ) + } + var patchIconArgument by remember { + mutableStateOf("icon") + } + + fun togglePatch(patch: Patch) { + val checked = enabledPatches.containsKey(patch.name) + + if (checked) { + enabledPatches.remove(patch.name) + } else { + enabledPatches[patch.name] = listOf() + } + } + + Box(modifier = Modifier.weight(1f)) { + LazyColumn { + items(PatchStore.all()) { + ListItem( + headlineContent = { + Text(localizedPatch(it)) + }, + supportingContent = { + if (!it.isRecommended) { + Text(stringResource(Res.string.use_at_your_risk)) + } + }, + trailingContent = { + Checkbox(enabledPatches.containsKey(it.name), { _ -> + togglePatch(it) + }) + }, + modifier = Modifier.clickable { + togglePatch(it) + } + ) + if (PATCH_REPLACE_ICON_LABEL == it) { + FadeVisibility(enabledPatches.containsKey(it.name)) { + ListItem( + headlineContent = { + Text(stringResource(Res.string.replace_icon_with_icon)) + }, + trailingContent = { + RadioButton(patchIconArgument == "icon", { + patchIconArgument = "icon" + }) + }, + modifier = Modifier.padding(24.dp, 0.dp, 0.dp, 0.dp).clickable { + patchIconArgument = "icon" + } + ) + } + FadeVisibility(enabledPatches.containsKey(it.name)) { + ListItem( + headlineContent = { + Text(stringResource(Res.string.replace_icon_with_cover)) + }, + trailingContent = { + RadioButton(patchIconArgument == "cover", { + patchIconArgument = "cover" + }) + }, + modifier = Modifier.padding(24.dp, 0.dp, 0.dp, 0.dp).clickable { + patchIconArgument = "cover" + } + ) + } + } + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + modifier = Modifier.weight(1f), + onClick = onCancel + ) { + Text(stringResource(Res.string.action_cancel)) + } + Button( + modifier = Modifier.weight(1f), + onClick = { + enabledPatches[PATCH_REPLACE_ICON_LABEL.name] = listOf(patchIconArgument) + onConfirm(enabledPatches) + } + ) { + Text(stringResource(Res.string.action_confirm)) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/BreathingIcon.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/BreathingIcon.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/BreathingIcon.kt rename to composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/BreathingIcon.kt index 1b86950..7e8cb87 100644 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/BreathingIcon.kt +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/BreathingIcon.kt @@ -1,4 +1,4 @@ -package moe.crx.ovrport.ui +package moe.crx.overport.app.composables import androidx.compose.animation.core.* import androidx.compose.material.icons.Icons diff --git a/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/DynamicScaffold.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/DynamicScaffold.kt new file mode 100644 index 0000000..bff0fd3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/DynamicScaffold.kt @@ -0,0 +1,93 @@ +package moe.crx.overport.app.composables + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import moe.crx.overport.app.AndroidPlatform +import moe.crx.overport.app.getPlatform +import moe.crx.overport.app.screenWidthDp + +data class BottomNavItem( + val name: String, + val selectedIcon: ImageVector, + val icon: ImageVector, + val content: @Composable (() -> Unit), +) + +@Composable +fun DynamicScaffold( + snackbarHost: @Composable (() -> Unit), + topBar: @Composable (() -> Unit), + navigationItems: List, + itemsEnabled: Boolean, +) { + var selectedItem by remember { mutableStateOf(navigationItems.firstOrNull()) } + val isTablet = screenWidthDp() > 600.dp + + Scaffold( + snackbarHost = snackbarHost, + modifier = Modifier + .fillMaxSize() + .then( + if (getPlatform() !is AndroidPlatform) Modifier else { + Modifier.clip(RoundedCornerShape(32.dp)) + } + ), + topBar = topBar, + bottomBar = { + if (!isTablet && navigationItems.size > 1) { + NavigationBar { + navigationItems.forEach { item -> + NavigationBarItem( + icon = { + Icon( + if (selectedItem == item) item.selectedIcon else item.icon, + contentDescription = item.name + ) + }, + label = { Text(item.name) }, + selected = selectedItem == item, + onClick = { selectedItem = item }, + enabled = itemsEnabled + ) + } + } + } + } + ) { innerPadding -> + Row( + modifier = Modifier.padding(innerPadding) + ) { + if (isTablet && navigationItems.size > 1) { + NavigationRail { + navigationItems.forEach { item -> + NavigationRailItem( + icon = { + Icon( + if (selectedItem == item) item.selectedIcon else item.icon, + contentDescription = item.name + ) + }, + label = { Text(item.name) }, + selected = selectedItem == item, + onClick = { selectedItem = item }, + enabled = itemsEnabled + ) + } + } + } + navigationItems.forEach { item -> + FadeVisibility(selectedItem == item) { + item.content() + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/ErrorDialog.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/ErrorDialog.kt new file mode 100644 index 0000000..d681f47 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/ErrorDialog.kt @@ -0,0 +1,23 @@ +package moe.crx.overport.app.composables + +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun ErrorDialog(title: String, message: String, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = { onDismiss() }, + title = { Text(title) }, + text = { Text(message, modifier = Modifier.verticalScroll(rememberScrollState())) }, + confirmButton = { + TextButton(onClick = { onDismiss() }) { + Text("Dismiss") + } + }, + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/FadeVisibility.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/FadeVisibility.kt similarity index 92% rename from composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/FadeVisibility.kt rename to composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/FadeVisibility.kt index a68a7ad..0fec411 100644 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/FadeVisibility.kt +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/FadeVisibility.kt @@ -1,4 +1,4 @@ -package moe.crx.ovrport.ui +package moe.crx.overport.app.composables import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope diff --git a/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/PatcherScreen.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/PatcherScreen.kt new file mode 100644 index 0000000..55e6913 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/PatcherScreen.kt @@ -0,0 +1,133 @@ +package moe.crx.overport.app.composables + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import moe.crx.overport.app.model.MainViewModel +import org.jetbrains.compose.resources.getString +import overportapp.composeapp.generated.resources.Res +import overportapp.composeapp.generated.resources.apk_file_exported +import overportapp.composeapp.generated.resources.unknown_error +import java.io.InputStream +import java.io.OutputStream + +@Composable +fun PatcherScreen( + viewModel: MainViewModel, + snackbarHostState: SnackbarHostState, + openFile: () -> Unit, + saveFile: (String) -> Unit, + openFlow: MutableSharedFlow?>, + saveFlow: MutableSharedFlow, +) { + val scope = rememberCoroutineScope() + var inputFile by rememberSaveable { mutableStateOf?>(null) } + var errorMessage by rememberSaveable { mutableStateOf?>(null) } + var patches by remember { mutableStateOf>>(mapOf()) } + + val inProgress = viewModel.working || viewModel.isApkLoaded() + val isPatcherVisible = !viewModel.working && viewModel.isApkLoaded() + + LaunchedEffect(openFlow) { + scope.launch { + openFlow.collect { + inputFile = it + } + } + } + + LaunchedEffect(saveFlow) { + scope.launch { + saveFlow.collect { stream -> + val outputStream = stream ?: return@collect + try { + viewModel.process(patches) + viewModel.export(outputStream) + scope.launch { + snackbarHostState.showSnackbar(getString(Res.string.apk_file_exported)) + } + } catch (ex: Throwable) { + ex.printStackTrace() + errorMessage = getString(Res.string.unknown_error) to ex.stackTraceToString() + } finally { + viewModel.cancel() + outputStream.close() + } + } + } + } + + FadeVisibility(!isPatcherVisible) { + StartingCard(inProgress, viewModel.currentProgress) { + openFile() + } + } + + FadeVisibility(isPatcherVisible) { + ApplicationInfoCard( + viewModel.currentAppName(), + viewModel.currentAppPackage(), + viewModel.currentAppVersion(), + viewModel.currentAppIcon(), + onCancel = { + scope.launch { + viewModel.cancel() + } + }, + onConfirm = { + patches = it + saveFile(viewModel.patchedName()) + } + ) + } + + if (inputFile != null && !inProgress) { + VersionManagerSheet( + viewModel, + onCancel = { + inputFile?.second?.close() + inputFile = null + }, + onSelected = { + scope.launch { + val (name, stream) = inputFile ?: return@launch + try { + viewModel.checkout(it, name) + viewModel.prepare(stream) + } catch (ex: Throwable) { + viewModel.cancel() + errorMessage = getString(Res.string.unknown_error) to ex.stackTraceToString() + } finally { + stream.close() + inputFile = null + } + } + } + ) + } + + errorMessage?.let { + ErrorDialog(it.first, it.second) { + errorMessage = null + } + } + + FadeVisibility(viewModel.currentProgressFloat != null && !isPatcherVisible) { + val progressAnimation by animateFloatAsState( + targetValue = viewModel.currentProgressFloat ?: 0f, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ) + LinearProgressIndicator( + { progressAnimation }, + modifier = Modifier.fillMaxWidth(), + drawStopIndicator = {} + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/StartingCard.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/StartingCard.kt similarity index 58% rename from composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/StartingCard.kt rename to composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/StartingCard.kt index 2aea93d..ec9dd30 100644 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/StartingCard.kt +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/StartingCard.kt @@ -1,15 +1,14 @@ -package moe.crx.ovrport.ui +package moe.crx.overport.app.composables import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Park import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.CornerRadius @@ -17,45 +16,65 @@ 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 moe.crx.ovrport.AndroidPlatform -import moe.crx.ovrport.getPlatform +import moe.crx.overport.app.AndroidPlatform +import moe.crx.overport.app.getPlatform +import moe.crx.overport.app.util.ModifierUtil.rounded 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 +import java.util.* + +val isWinter = Calendar.getInstance().get(Calendar.MONTH).let { it == 0 || it == 1 || it == 11 } @Composable @Preview fun StartingCard( - innerPadding: PaddingValues = PaddingValues(16.dp), showLoading: Boolean = false, + currentString: String? = null, 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() + if (!showLoading) { + 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()) + ) + if (isWinter) { + drawRoundRect( + color = Color(0xFFEF5350), + style = Stroke( + width = 4.dp.toPx(), + pathEffect = PathEffect.dashPathEffect( + floatArrayOf( + 16.dp.toPx(), + 16.dp.toPx() + ), 0f + ) + ), + cornerRadius = CornerRadius(32.dp.toPx()) ) - ), - cornerRadius = CornerRadius(32.dp.toPx()) - ) + } + } } - .clip(RoundedCornerShape(32.dp)) + .rounded(32.dp) .clickable(!showLoading, onClick = onCardClick), colors = CardDefaults.cardColors( containerColor = Color.Transparent @@ -70,7 +89,11 @@ fun StartingCard( ) { if (!showLoading) { Icon( - Icons.Default.Download, + if (isWinter) { + Icons.Default.Park + } else { + Icons.Default.Download + }, contentDescription = null, modifier = Modifier .padding(16.dp) @@ -96,7 +119,7 @@ fun StartingCard( .height(96.dp) ) Text( - text = stringResource(Res.string.decoding_apk) + text = currentString ?: stringResource(Res.string.decoding_apk) ) } } diff --git a/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/VersionListItem.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/VersionListItem.kt new file mode 100644 index 0000000..d55d3b0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/VersionListItem.kt @@ -0,0 +1,76 @@ +package moe.crx.overport.app.composables + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.Badge +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import moe.crx.overport.app.util.ModifierUtil.rounded +import moe.crx.overport.versions.OverportRelease +import moe.crx.overport.versions.VersionManager.Companion.isIncompatible +import org.jetbrains.compose.resources.stringResource +import overportapp.composeapp.generated.resources.* + +@Composable +fun VersionListItem(release: OverportRelease, index: Int, isInstalled: Boolean, onSelected: () -> Unit) { + ListItem( + modifier = Modifier.rounded(8.dp).clickable(!isIncompatible(release)) { + onSelected() + }, + headlineContent = { + FlowRow( + itemVerticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(release.version) + if (index == 0) { + Badge(containerColor = Color(0xFF1157CE)) { + Text(stringResource(Res.string.tag_latest)) + } + } + if (release.isExperimental) { + Badge(containerColor = Color(0xFFB16300)) { + Text(stringResource(Res.string.tag_experimental)) + } + } + if (release.isCustom) { + Badge(containerColor = Color(0xFFB60D6E)) { + Text(stringResource(Res.string.tag_custom)) + } + } + } + }, + supportingContent = { + if (isInstalled) { + if (!isIncompatible(release)) { + Text(stringResource(Res.string.status_installed)) + } else { + Text(stringResource(Res.string.status_installed_incompatible)) + } + } else { + if (!isIncompatible(release)) { + Text(stringResource(Res.string.status_not_installed)) + } else { + Text(stringResource(Res.string.status_not_installed_incompatible)) + } + } + }, + trailingContent = { + if (!isInstalled && !isIncompatible(release)) { + Icon( + Icons.Default.Download, + contentDescription = null, + ) + } + } + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/VersionManagerSheet.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/VersionManagerSheet.kt new file mode 100644 index 0000000..f70cd24 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/composables/VersionManagerSheet.kt @@ -0,0 +1,86 @@ +package moe.crx.overport.app.composables + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import moe.crx.overport.app.model.MainViewModel +import moe.crx.overport.versions.OverportRelease +import org.jetbrains.compose.resources.stringResource +import overportapp.composeapp.generated.resources.Res +import overportapp.composeapp.generated.resources.select_version_title +import overportapp.composeapp.generated.resources.you_have_no_versions + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VersionManagerSheet(viewModel: MainViewModel, onSelected: (String) -> Unit, onCancel: () -> Unit) { + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + var available by remember { mutableStateOf?>(null) } + + LaunchedEffect(Unit) { + scope.launch { + withContext(Dispatchers.IO) { + available = viewModel.versionManager.available() + } + } + } + + ModalBottomSheet( + modifier = Modifier.fillMaxHeight(), + onDismissRequest = { onCancel() }, + ) { + if (available == null) { + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + } + return@ModalBottomSheet + } + + val available = available ?: listOf() + val installed = viewModel.versionManager.installed() + val noMoreAvailable = installed.filter { rel -> available.firstOrNull { rel.version == it.version } == null } + + Text( + text = stringResource(Res.string.select_version_title), + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.headlineMedium, + ) + + if (available.isEmpty() && installed.isEmpty()) { + Text( + text = stringResource(Res.string.you_have_no_versions), + modifier = Modifier.padding(16.dp, 0.dp), + textAlign = TextAlign.Center, + ) + } + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(16.dp, 0.dp), + ) { + itemsIndexed(available + noMoreAvailable) { index, release -> + val isInstalled = installed.firstOrNull { it.version == release.version } != null + VersionListItem(release, index, isInstalled) { + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + onSelected(release.version) + } + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/model/GithubRelease.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/model/GithubRelease.kt similarity index 89% rename from composeApp/src/commonMain/kotlin/moe/crx/ovrport/model/GithubRelease.kt rename to composeApp/src/commonMain/kotlin/moe/crx/overport/app/model/GithubRelease.kt index b8f0506..b6cca91 100644 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/model/GithubRelease.kt +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/model/GithubRelease.kt @@ -1,4 +1,4 @@ -package moe.crx.ovrport.model +package moe.crx.overport.app.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/composeApp/src/commonMain/kotlin/moe/crx/overport/app/model/MainViewModel.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/model/MainViewModel.kt new file mode 100644 index 0000000..7949542 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/model/MainViewModel.kt @@ -0,0 +1,136 @@ +package moe.crx.overport.app.model + +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.overport.app.decodeBitmap +import moe.crx.overport.patches.IconResizer +import moe.crx.overport.patches.PLATFORM_ICON_RESIZER +import moe.crx.overport.patching.PatcherContext +import moe.crx.overport.utils.HttpUtil.download +import moe.crx.overport.versions.VersionManager +import org.jetbrains.compose.resources.getString +import overportapp.composeapp.generated.resources.* +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.io.OutputStream + +class MainViewModel(val dataDirectory: File, platformIconResizer: IconResizer) : ViewModel() { + companion object { + private val json = Json { ignoreUnknownKeys = true } + const val RELEASES_URL = "https://api.github.com/repos/ovrport/app/releases" + } + + init { + PLATFORM_ICON_RESIZER = platformIconResizer + } + + private var patcherState by mutableStateOf(null) + var versionManager by mutableStateOf(VersionManager(dataDirectory)) + private set + var working by mutableStateOf(false) + private set + var currentProgress by mutableStateOf(null) + private set + var currentProgressFloat by mutableStateOf(null) + private set + + private suspend fun usePatcher(block: suspend PatcherContext.() -> Unit) { + working = true + withContext(Dispatchers.IO) { + patcherState?.block() + } + working = false + currentProgress = null + } + + suspend fun checkout(overportVersion: String, fileName: String) { + patcherState = PatcherContext( + overportVersion, + dataDirectory, + fileName, + "" + ) + usePatcher { + currentProgress = getString(Res.string.progress_preparing) + currentProgressFloat = 0.0f + checkout() + } + } + + suspend fun prepare(inputStream: InputStream) { + usePatcher { + currentProgress = getString(Res.string.progress_decompiling) + currentProgressFloat = 0.25f + prepare(inputStream) + } + } + + fun patchedName(): String { + return patcherState?.outputFileName() ?: "output.apk" + } + + suspend fun process(patches: Map>) { + usePatcher { + currentProgress = getString(Res.string.progress_patching) + currentProgressFloat = 0.50f + patch(patches) + } + } + + suspend fun export(outputStream: OutputStream) { + usePatcher { + currentProgress = getString(Res.string.progress_compiling) + currentProgressFloat = 0.75f + export(outputStream) + } + } + + suspend fun cancel() { + usePatcher { + currentProgress = getString(Res.string.progress_cleaning) + currentProgressFloat = 1.0f + cleanup() + } + currentProgressFloat = null + patcherState = null + } + + fun currentAppName(): String? { + return patcherState?.appName() + } + + fun currentAppPackage(): String? { + return patcherState?.appPackage() + } + + fun currentAppVersion(): String? { + return patcherState?.appVersionName() + } + + fun currentAppIcon(): ImageBitmap? { + return patcherState?.appIcon()?.let { file -> + FileInputStream(file).use { stream -> + stream.readBytes().decodeBitmap() + } + } + } + + fun isApkLoaded(): Boolean { + return patcherState?.isApkLoaded() ?: false + } + + fun versionToUpdate(): GithubRelease? { + return runCatching { + json + .decodeFromString>(String(download(RELEASES_URL))) + .reduceOrNull { left, right -> if (left.publishedAt > right.publishedAt) left else right } + }.getOrNull() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/theme/Color.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/theme/Color.kt similarity index 87% rename from composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/theme/Color.kt rename to composeApp/src/commonMain/kotlin/moe/crx/overport/app/theme/Color.kt index e1a409a..7c6f7ec 100644 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/theme/Color.kt +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/theme/Color.kt @@ -1,4 +1,4 @@ -package moe.crx.ovrport.ui.theme +package moe.crx.overport.app.theme import androidx.compose.ui.graphics.Color diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/theme/Theme.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/theme/Theme.kt rename to composeApp/src/commonMain/kotlin/moe/crx/overport/app/theme/Theme.kt index 33a4d51..01b46c8 100644 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/theme/Theme.kt @@ -1,11 +1,10 @@ -package moe.crx.ovrport.ui.theme +package moe.crx.overport.app.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 -import androidx.compose.ui.graphics.Color private val DarkColorScheme = darkColorScheme( primary = Purple80, diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/theme/Type.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/theme/Type.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/theme/Type.kt rename to composeApp/src/commonMain/kotlin/moe/crx/overport/app/theme/Type.kt index 82ab59f..e75b871 100644 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/theme/Type.kt +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/theme/Type.kt @@ -1,4 +1,4 @@ -package moe.crx.ovrport.ui.theme +package moe.crx.overport.app.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle diff --git a/composeApp/src/commonMain/kotlin/moe/crx/overport/app/util/ModifierUtil.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/util/ModifierUtil.kt new file mode 100644 index 0000000..998092d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/util/ModifierUtil.kt @@ -0,0 +1,14 @@ +package moe.crx.overport.app.util + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp + +object ModifierUtil { + @Composable + fun Modifier.rounded(radius: Dp): Modifier { + return clip(RoundedCornerShape(radius)) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/overport/app/util/PatchLocalizer.kt b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/util/PatchLocalizer.kt new file mode 100644 index 0000000..a71c14b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/crx/overport/app/util/PatchLocalizer.kt @@ -0,0 +1,39 @@ +package moe.crx.overport.app.util + +import androidx.compose.runtime.Composable +import moe.crx.overport.patches.* +import moe.crx.overport.patching.Patch +import org.jetbrains.compose.resources.stringResource +import overportapp.composeapp.generated.resources.* + +object PatchLocalizer { + val LOCALIZED_PATCHES = mapOf( + PATCH_COPY_LIBRARIES to Res.string.patch_copy_libraries, + PATCH_COPY_OVRPLUGIN_VRAPI to Res.string.patch_copy_ovrplugin_vrapi, + PATCH_REPLACE_ICON_LABEL to Res.string.patch_replace_icon_label, + PATCH_FIX_MIN_ANDROID_SDK to Res.string.patch_fix_min_android_sdk, + PATCH_GENERATE_CONFIG to Res.string.patch_generate_config, + PATCH_REMOVE_LOCALIZED_NAMES to Res.string.patch_remove_localized_names, + PATCH_CLEAN_UP_FRIDA to Res.string.patch_clean_up_frida, + PATCH_OCULUS_UNITY to Res.string.patch_oculus_unity, + PATCH_OCULUS_UNREAL to Res.string.patch_oculus_unreal, + PATCH_VR_METADATA to Res.string.patch_vr_metadata, + PATCH_LAUNCHER_ENTRY to Res.string.patch_launcher_entry, + PATCH_REMOVE_USES_LIBRARY to Res.string.patch_remove_uses_library, + PATCH_FIX_UNREAL_CRASH to Res.string.patch_fix_unreal_crash, + PATCH_META_XR_AUDIO to Res.string.patch_meta_xr_audio, + PATCH_MARK_AS_DEBUGGABLE to Res.string.patch_mark_as_debuggable, + PATCH_MARK_ALLOW_BACKUP to Res.string.patch_mark_allow_backup, + PATCH_REMOVE_UNREAL_FORCE_QUIT to Res.string.patch_remove_unreal_force_quit, + PATCH_FORCE_PASSTHROUGH to Res.string.patch_force_passthrough, + PATCH_DISABLE_SPACE_WARP to Res.string.patch_disable_space_warp, + PATCH_DISABLE_CONTROLLER_OFFSET to Res.string.patch_disable_controller_offset, + ) + + @Composable + fun localizedPatch(patch: Patch): String { + return LOCALIZED_PATCHES[patch]?.let { + stringResource(it) + } ?: patch.name + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/Platform.kt b/composeApp/src/commonMain/kotlin/moe/crx/ovrport/Platform.kt deleted file mode 100644 index 4eec4de..0000000 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/Platform.kt +++ /dev/null @@ -1,14 +0,0 @@ -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.1.0" \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/model/ConfigGenerator.kt b/composeApp/src/commonMain/kotlin/moe/crx/ovrport/model/ConfigGenerator.kt deleted file mode 100644 index 6cc09c7..0000000 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/model/ConfigGenerator.kt +++ /dev/null @@ -1,46 +0,0 @@ -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 = 0, - val path: String = "", - val type: String = "store" -) - -@Serializable -data class FrdaParameters( - val assets: List = listOf(), - val skus: List = listOf() -) - -@Serializable -data class FrdaInteraction( - val parameters: FrdaParameters = FrdaParameters(), -) - -@Serializable -data class FrdaConfig( - val interaction: FrdaInteraction = FrdaInteraction(), -) - -private val json = Json { ignoreUnknownKeys = true } - -fun generateConfig(jsonString: String): String { - return json.decodeFromString(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) }) - ) - ) - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/model/GithubIssues.kt b/composeApp/src/commonMain/kotlin/moe/crx/ovrport/model/GithubIssues.kt deleted file mode 100644 index b36e937..0000000 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/model/GithubIssues.kt +++ /dev/null @@ -1,32 +0,0 @@ -package moe.crx.ovrport.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class GithubIssueLabel( - @SerialName("id") - val id: Long, - @SerialName("name") - val name: String, - @SerialName("color") - val color: String, -) - -@Serializable -data class GithubIssue( - @SerialName("html_url") - var htmlUrl: String, - @SerialName("title") - val title: String, - @SerialName("created_at") - val createdAt: String, - @SerialName("labels") - val labels: List -) - -@Serializable -data class GithubIssues( - @SerialName("items") - val items: List -) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/Constants.kt b/composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/Constants.kt deleted file mode 100644 index 24d004e..0000000 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/Constants.kt +++ /dev/null @@ -1,130 +0,0 @@ -package moe.crx.ovrport.patch - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import kotlinx.serialization.json.Json -import moe.crx.ovrport.model.GithubIssues -import moe.crx.ovrport.patch.Constants.UNKNOWN_COMPATIBILITY -import moe.crx.ovrport.utils.HttpUtil.download -import org.jetbrains.compose.resources.StringResource -import overportapp.composeapp.generated.resources.* -import overportapp.composeapp.generated.resources.Res -import overportapp.composeapp.generated.resources.status_has_problems -import overportapp.composeapp.generated.resources.status_not_working -import overportapp.composeapp.generated.resources.status_unknown -import java.net.URLEncoder - -private val json = Json { ignoreUnknownKeys = true } - -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" - const val ISSUES_URL = "https://api.github.com/search/issues?q=%s+repo:ovrport/tracker" - private const val TRACKER_URL = "https://github.com/ovrport/tracker/issues" - val UNKNOWN_COMPATIBILITY = CompatibilityStatusInfo(CompatibilityStatus.UNKNOWN, TRACKER_URL) - val LOADING_COMPATIBILITY = CompatibilityStatusInfo(CompatibilityStatus.LOADING, TRACKER_URL) -} - -enum class CompatibilityStatus( - val labelName: String, - val icon: ImageVector, - val nameResource: StringResource, - val cardColor: Color, - val contentColor: Color, -) { - LOADING( - "Loading", - Icons.Default.Sync, - Res.string.status_loading, - Color(0xFF483f77), - Color(0xFFe6deff), - ), - UNKNOWN( - "Unknown", - Icons.Default.QuestionMark, - Res.string.status_unknown, - Color(0xFF354479), - Color(0xFFDCE1FF), - ), - NOT_WORKING( - "Not working", - Icons.Default.Close, - Res.string.status_not_working, - Color(0xFF73332f), - Color(0xFFffdad7), - ), - HAS_PROBLEMS( - "Has problems", - Icons.Default.Warning, - Res.string.status_has_problems, - Color(0xFF5b4300), - Color(0xFFffdf9f), - ), - PLAYABLE( - "Playable", - Icons.Default.Check, - Res.string.status_playable, - Color(0xFF2d4f1d), - Color(0xFFc4efac), - ), -} - -data class CompatibilityStatusInfo( - val status: CompatibilityStatus, - val url: String, -) - - -fun getCompatibilityStatus( - applicationName: String?, - applicationPackage: String? -): CompatibilityStatusInfo { - val pkg = applicationName ?: "" - val name = applicationPackage ?: "" - - runCatching { - val issue = json - .decodeFromString( - String( - download( - Constants.ISSUES_URL.format( - URLEncoder.encode( - pkg, - Charsets.UTF_8 - ) - ) - ) - ) - ) - .items.firstOrNull { it.title.contains(pkg) || it.title.contains(name) } - ?: json - .decodeFromString( - String( - download( - Constants.ISSUES_URL.format( - URLEncoder.encode( - name, - Charsets.UTF_8 - ) - ) - ) - ) - ) - .items.firstOrNull { it.title.contains(pkg) || it.title.contains(name) } - - issue?.labels?.forEach { label -> - CompatibilityStatus.entries.forEach { status -> - if (status.labelName == label.name) { - return CompatibilityStatusInfo(status, issue.htmlUrl) - } - } - } - } - - return UNKNOWN_COMPATIBILITY -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/Exceptions.kt b/composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/Exceptions.kt deleted file mode 100644 index 5fea22e..0000000 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/Exceptions.kt +++ /dev/null @@ -1,5 +0,0 @@ -package moe.crx.ovrport.patch - -class CantUpdateOverportException : RuntimeException() - -class CantDecodeApkException : RuntimeException() diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/PatchStore.kt b/composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/PatchStore.kt deleted file mode 100644 index 7f2ff0f..0000000 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/PatchStore.kt +++ /dev/null @@ -1,248 +0,0 @@ -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() - - 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("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("packages") { - takeEach { - take("specs") { - takeEach { - take("types") { - takeEach { - val configLanguage = elem("config").elem("language") - - take("entries") { - takeEach { - this.takeIf { - elem("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") }) { - val hasIntent = elem("nodes").elemEach { named("category") }.map { - when (it.attributeName()) { - "com.oculus.intent.category.VR" -> true - "android.intent.category.LAUNCHER" -> true - else -> false - } - }.reduceOrNull { l, r -> l || r } ?: false - - takeNodes { - if (!hasIntent) this else { - JSONArray() - .put(createAction("android.intent.action.MAIN")) - .put(createCategory("android.intent.category.LAUNCHER")) - .put(createCategory("com.oculus.intent.category.VR")) - .put(createCategory("com.yvr.intent.category.VR")) - } - } - } - } - } - }?.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 (readText().isBlank()) { - 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("libMetaXRAudioWwise.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" - ) - } - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/Patcher.kt b/composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/Patcher.kt deleted file mode 100644 index 921bb08..0000000 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/Patcher.kt +++ /dev/null @@ -1,225 +0,0 @@ -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) { - 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 { - it.delete() - 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 -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/AppContent.kt b/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/AppContent.kt deleted file mode 100644 index f46eedb..0000000 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/AppContent.kt +++ /dev/null @@ -1,132 +0,0 @@ -package moe.crx.ovrport.ui - -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.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.withContext -import moe.crx.ovrport.AndroidPlatform -import moe.crx.ovrport.getCurrentVersion -import moe.crx.ovrport.getPlatform -import moe.crx.ovrport.model.GithubRelease -import moe.crx.ovrport.patch.Constants -import moe.crx.ovrport.patch.Patch -import moe.crx.ovrport.ui.theme.OverportTheme -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) -> Unit -) { - OverportTheme(darkTheme = true) { - var versionToUpdate by remember { mutableStateOf(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, - ) - } - }, - actions = { - FadeVisibility(versionToUpdate?.name != null && versionToUpdate?.name != getCurrentVersion()) { - 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, - 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 - - FadeVisibility(!isPatcherVisible) { - StartingCard(innerPadding, inProgress) { - onOpen() - } - } - - FadeVisibility(isPatcherVisible) { - ApplicationInfoCard( - innerPadding, - viewModel.currentAppName(), - viewModel.currentAppPackage(), - viewModel.currentAppVersion(), - viewModel.currentAppIcon(), - onCancel = { - onCancel() - }, - onConfirm = { - onConfirm(it) - } - ) - } - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/ApplicationInfo.kt b/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/ApplicationInfo.kt deleted file mode 100644 index 76f62ab..0000000 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/ApplicationInfo.kt +++ /dev/null @@ -1,52 +0,0 @@ -package moe.crx.ovrport.ui - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Widgets -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import overportapp.composeapp.generated.resources.Res -import overportapp.composeapp.generated.resources.unknown_package - -@Composable -fun ApplicationInfo( - applicationName: String? = "Application", - applicationPackage: String? = "com.company.application", - applicationVersion: String? = "1.0.0", - applicationIcon: ImageBitmap? = null, -) { - Row( - 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), - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/ApplicationInfoCard.kt b/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/ApplicationInfoCard.kt deleted file mode 100644 index a7f0d8b..0000000 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/ApplicationInfoCard.kt +++ /dev/null @@ -1,103 +0,0 @@ -package moe.crx.ovrport.ui - -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.material3.Button -import androidx.compose.material3.Checkbox -import androidx.compose.material3.ListItem -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -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 - -@OptIn(ExperimentalLayoutApi::class) -@Composable -@Preview -fun ApplicationInfoCard( - innerPadding: PaddingValues = PaddingValues(16.dp), - applicationName: String? = "Application", - applicationPackage: String? = "", - applicationVersion: String? = "1.0.0", - applicationIcon: ImageBitmap? = null, - onCancel: () -> Unit = {}, - onConfirm: (List) -> Unit = {} -) { - Column( - modifier = Modifier.padding(innerPadding), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - FlowRow( - modifier = Modifier.fillMaxWidth().padding(8.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - ApplicationInfo(applicationName, applicationPackage, applicationVersion, applicationIcon) - ApplicationStatus(applicationName, applicationPackage) - } - - 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") - } - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/ApplicationStatus.kt b/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/ApplicationStatus.kt deleted file mode 100644 index 6f3b0f4..0000000 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/ApplicationStatus.kt +++ /dev/null @@ -1,57 +0,0 @@ -package moe.crx.ovrport.ui - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -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.withContext -import moe.crx.ovrport.patch.Constants.LOADING_COMPATIBILITY -import moe.crx.ovrport.patch.getCompatibilityStatus -import org.jetbrains.compose.resources.stringResource - -@Composable -fun ApplicationStatus( - applicationName: String? = "Application", - applicationPackage: String? = "", -) { - var statusInfo by remember(applicationPackage) { mutableStateOf(LOADING_COMPATIBILITY) } - val urlHandler = LocalUriHandler.current - - LaunchedEffect(applicationPackage) { - withContext(Dispatchers.IO) { - statusInfo = getCompatibilityStatus(applicationName, applicationPackage) - } - } - - Card(colors = CardDefaults.cardColors(statusInfo.status.cardColor, statusInfo.status.contentColor)) { - Row( - verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clip( - RoundedCornerShape(16.dp) - ).clickable { - urlHandler.openUri(statusInfo.url) - }) { - Icon( - statusInfo.status.icon, - contentDescription = null, - modifier = Modifier.padding(16.dp), - ) - Text( - stringResource(statusInfo.status.nameResource), - fontSize = 18.sp, - modifier = Modifier.padding(0.dp, 16.dp, 16.dp, 16.dp), - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/MainViewModel.kt b/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/MainViewModel.kt deleted file mode 100644 index b78f327..0000000 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/ui/MainViewModel.kt +++ /dev/null @@ -1,95 +0,0 @@ -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(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) { - 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>(String(download(Constants.RELEASES_URL))) - .reduceOrNull { left, right -> if (left.publishedAt > right.publishedAt) left else right } - }.getOrNull() - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/utils/ManifestUtil.kt b/composeApp/src/commonMain/kotlin/moe/crx/ovrport/utils/ManifestUtil.kt deleted file mode 100644 index 72f02d3..0000000 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/utils/ManifestUtil.kt +++ /dev/null @@ -1,107 +0,0 @@ -package moe.crx.ovrport.utils - -import com.reandroid.json.JSONArray -import com.reandroid.json.JSONObject - -fun JSONObject?.named(name: String?): Boolean { - return elem("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("attributes") - .elemEach { named("name") } - .firstOrNull() - .elem("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) - ) - ) -} - -fun createAction(value: String): JSONObject { - return JSONObject() - .put("node_type", "element") - .put("name", "action") - .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) - ) - ) -} diff --git a/composeApp/src/desktopMain/kotlin/moe/crx/overport/app/ComposeMain.kt b/composeApp/src/desktopMain/kotlin/moe/crx/overport/app/ComposeMain.kt new file mode 100644 index 0000000..fd9cf8a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/moe/crx/overport/app/ComposeMain.kt @@ -0,0 +1,131 @@ +package moe.crx.overport.app + +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.runtime.* +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.window.Window +import androidx.compose.ui.window.application +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import moe.crx.overport.app.composables.AppContent +import moe.crx.overport.app.model.MainViewModel +import moe.crx.overport.utils.DesktopUtil.defaultWorkspace +import moe.crx.overport.utils.ImageIOIconResizer +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import overportapp.composeapp.generated.resources.Res +import overportapp.composeapp.generated.resources.select_a_file +import overportapp.composeapp.generated.resources.window_icon +import java.awt.FileDialog +import java.awt.Frame +import java.awt.datatransfer.DataFlavor +import java.io.* + +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +fun main(args: Array) = application { + Window( + onCloseRequest = ::exitApplication, + title = "overport", + icon = painterResource(Res.drawable.window_icon), + ) { + val dataDir = defaultWorkspace() + dataDir.mkdirs() + + val selectFileString = stringResource(Res.string.select_a_file) + val viewModel: MainViewModel = viewModel { MainViewModel(dataDir, ImageIOIconResizer) } + val openFlow = remember { MutableSharedFlow?>() } + val saveFlow = remember { MutableSharedFlow() } + var lastOpenedDirectory by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + + fun openFile(file: File?) { + if (file == null || !file.isFile) { + scope.launch { + openFlow.emit(null) + } + return + } + + scope.launch { + lastOpenedDirectory = file.parentFile.absolutePath + openFlow.emit(file.name to FileInputStream(file)) + } + } + + val dragAndDropTarget = remember { + object : DragAndDropTarget { + override fun onDrop(event: DragAndDropEvent): Boolean { + val value: List? = event.awtTransferable + .takeIf { it.isDataFlavorSupported(DataFlavor.javaFileListFlavor) } + ?.getTransferData(DataFlavor.javaFileListFlavor) + ?.let { it as? List<*> } + ?.filterIsInstance() + + if (value?.size == 1) { + scope.launch { + openFile(value.first()) + } + + return true + } + + return false + } + } + } + + LaunchedEffect(args) { + if (args.isNotEmpty()) { + openFile(File(args.first())) + } + } + + Box( + modifier = Modifier.fillMaxSize().dragAndDropTarget( + shouldStartDragAndDrop = { !viewModel.working && !viewModel.isApkLoaded() }, + target = dragAndDropTarget + ) + ) { + AppContent( + viewModel = viewModel, + openFile = { + val fileDialog = FileDialog(Frame(), selectFileString, FileDialog.LOAD).apply { + directory = lastOpenedDirectory + isVisible = true + } + + val file = fileDialog.file?.let { File(fileDialog.directory).resolve(it) } + + openFile(file) + }, + saveFile = { name -> + val fileDialog = FileDialog(Frame(), selectFileString, FileDialog.SAVE).apply { + directory = lastOpenedDirectory + file = name + isVisible = true + } + + val file = fileDialog.file?.let { File(fileDialog.directory).resolve(it) } + + scope.launch { + if (file == null) { + saveFlow.emit(null) + } else { + saveFlow.emit(FileOutputStream(file)) + } + } + }, + openFlow = openFlow, + saveFlow = saveFlow, + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/moe/crx/overport/app/Platform.jvm.kt b/composeApp/src/desktopMain/kotlin/moe/crx/overport/app/Platform.jvm.kt new file mode 100644 index 0000000..6315aaa --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/moe/crx/overport/app/Platform.jvm.kt @@ -0,0 +1,23 @@ +package moe.crx.overport.app + +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.jetbrains.skia.Image + +actual fun getPlatform(): Platform = JVMPlatform("Java ${System.getProperty("java.version")}") + +actual fun ByteArray.decodeBitmap(): ImageBitmap { + return Image.makeFromEncoded(this).toComposeImageBitmap() +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +actual fun screenWidthDp(): Dp { + return (LocalWindowInfo.current.containerSize.width / LocalDensity.current.density).dp +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/moe/crx/ovrport/Platform.jvm.kt b/composeApp/src/desktopMain/kotlin/moe/crx/ovrport/Platform.jvm.kt deleted file mode 100644 index c478466..0000000 --- a/composeApp/src/desktopMain/kotlin/moe/crx/ovrport/Platform.jvm.kt +++ /dev/null @@ -1,3 +0,0 @@ -package moe.crx.ovrport - -actual fun getPlatform(): Platform = JVMPlatform("Java ${System.getProperty("java.version")}") \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/moe/crx/ovrport/main.kt b/composeApp/src/desktopMain/kotlin/moe/crx/ovrport/main.kt deleted file mode 100644 index ff8e233..0000000 --- a/composeApp/src/desktopMain/kotlin/moe/crx/ovrport/main.kt +++ /dev/null @@ -1,157 +0,0 @@ -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? = event.awtTransferable - .takeIf { it.isDataFlavorSupported(DataFlavor.javaFileListFlavor) } - ?.getTransferData(DataFlavor.javaFileListFlavor) - ?.let { it as? List<*> } - ?.filterIsInstance() - - 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() - } - }, - ) - } - } -} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 0d6fa65..fb0f324 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,4 +10,4 @@ android.nonTransitiveRClass=true android.useAndroidX=true #Application -appVersion=1.1.0 \ No newline at end of file +appVersion=1.2.0 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c69acc9..18d5cc8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,17 +1,18 @@ [versions] -agp = "8.5.2" -android-compileSdk = "34" -android-minSdk = "24" -android-targetSdk = "34" -androidx-activityCompose = "1.9.3" # Do not update it -androidx-lifecycle = "2.8.4" -compose-multiplatform = "1.7.0" -kotlin = "2.1.0" -kotlinx-coroutines = "1.10.1" -apksig = "8.10.0-alpha07" -bcpkix = "1.80" -kotlinxSerializationJson = "1.8.0" -lifecycleViewmodelCompose = "2.8.2" +agp = "8.12.3" +android-compileSdk = "36" +android-minSdk = "29" +android-targetSdk = "36" +androidx-activityCompose = "1.12.1" +androidx-lifecycle = "2.9.6" +compose-multiplatform = "1.9.3" +kotlin = "2.2.21" +kotlinx-coroutines = "1.10.2" +apksig = "8.12.3" +bcpkix = "1.83" +kotlinx-serialization = "1.9.0" +lifecycle-viewmodel-compose = "2.9.6" +compose-hot-reload = "1.0.0" [libraries] androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activityCompose" } @@ -19,11 +20,12 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } -androidx-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +androidx-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle-viewmodel-compose" } android-tools-build = { group = "com.android.tools.build", name = "apksig", version.ref = "apksig" } bouncycastle-pkix = { group = "org.bouncycastle", name = "bcpkix-jdk18on", version.ref = "bcpkix" } -kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name ="kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name ="kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name ="kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } @@ -31,4 +33,5 @@ androidLibrary = { id = "com.android.library", version.ref = "agp" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hot-reload" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0..37f853b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/overportcli/build.gradle.kts b/overportcli/build.gradle.kts new file mode 100644 index 0000000..d00bc96 --- /dev/null +++ b/overportcli/build.gradle.kts @@ -0,0 +1,31 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +plugins { + kotlin("jvm") + kotlin("plugin.serialization") version "2.2.21" + application + id("com.gradleup.shadow") version "9.2.2" +} + +group = "moe.crx" +version = "1.2.0" + +dependencies { + implementation(libs.android.tools.build) + implementation(libs.bouncycastle.pkix) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(fileTree("libs")) +} + +tasks.test { + useJUnitPlatform() +} + +application { + mainClass = "moe.crx.overport.cli.CliMainKt" +} + +tasks.named("shadowJar") { + mergeServiceFiles() +} \ No newline at end of file diff --git a/composeApp/libs/ARSCLib-1.3.6.jar b/overportcli/libs/ARSCLib-1.3.8.jar similarity index 82% rename from composeApp/libs/ARSCLib-1.3.6.jar rename to overportcli/libs/ARSCLib-1.3.8.jar index 8f26adc..976a869 100644 Binary files a/composeApp/libs/ARSCLib-1.3.6.jar and b/overportcli/libs/ARSCLib-1.3.8.jar differ diff --git a/composeApp/libs/smali.jar b/overportcli/libs/smali.jar similarity index 100% rename from composeApp/libs/smali.jar rename to overportcli/libs/smali.jar diff --git a/overportcli/src/main/java/moe/crx/overport/cli/CliMain.kt b/overportcli/src/main/java/moe/crx/overport/cli/CliMain.kt new file mode 100644 index 0000000..e9f3440 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/cli/CliMain.kt @@ -0,0 +1,28 @@ +package moe.crx.overport.cli + +import kotlinx.coroutines.runBlocking +import moe.crx.overport.cli.CommandStore.COMMANDS +import moe.crx.overport.commands.DisplayHelpCommand + +abstract class CliCommand { + abstract fun name(): String + abstract fun description(): String + abstract fun printHelp(): Boolean + abstract suspend fun execute(args: List): Boolean + + override fun toString() = name() +} + +fun main(args: Array) { + val command = args.firstOrNull()?.let { COMMANDS[it] } ?: run { + DisplayHelpCommand.printHelp() + return + } + + val successful = runBlocking { + command.execute(args.drop(1)) + } + if (!successful) { + command.printHelp() + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/cli/CommandStore.kt b/overportcli/src/main/java/moe/crx/overport/cli/CommandStore.kt new file mode 100644 index 0000000..0ad2c7e --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/cli/CommandStore.kt @@ -0,0 +1,15 @@ +package moe.crx.overport.cli + +import moe.crx.overport.commands.DisplayHelpCommand +import moe.crx.overport.commands.ListPatchesCommand +import moe.crx.overport.commands.PatchApplicationCommand +import moe.crx.overport.commands.VersionManagerCommand + +object CommandStore { + val COMMANDS = listOf( + ListPatchesCommand, + PatchApplicationCommand, + DisplayHelpCommand, + VersionManagerCommand, + ).associateBy { it.name() } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/commands/DisplayHelpCommand.kt b/overportcli/src/main/java/moe/crx/overport/commands/DisplayHelpCommand.kt new file mode 100644 index 0000000..a4e745a --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/commands/DisplayHelpCommand.kt @@ -0,0 +1,44 @@ +package moe.crx.overport.commands + +import moe.crx.overport.cli.CliCommand +import moe.crx.overport.cli.CommandStore.COMMANDS +import moe.crx.overport.utils.PrintUtil.fspace +import moe.crx.overport.utils.PrintUtil.uspace +import moe.crx.overport.versions.VersionManager + +object DisplayHelpCommand : CliCommand() { + override fun name() = "help" + override fun description() = "Display this message." + + override fun printHelp(): Boolean { + println("overport cli utility, version ${VersionManager.VERSION}") + println("usage: overport [args]") + println() + println("Available commands:") + COMMANDS.values.forEach { + print(fspace()) + print(it.name()) + print(uspace(it, COMMANDS.values)) + println(it.description()) + } + println() + println("overport help shows help information for that specific command.") + println() + println("For additional info, see: https://github.com/ovrport/app") + return true + } + + override suspend fun execute(args: List): Boolean { + val command = args.firstOrNull()?.let { COMMANDS[it] } ?: run { + DisplayHelpCommand.printHelp() + return true + } + + val hasHelp = command.printHelp() + if (!hasHelp) { + printHelp() + } + + return true + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/commands/ListPatchesCommand.kt b/overportcli/src/main/java/moe/crx/overport/commands/ListPatchesCommand.kt new file mode 100644 index 0000000..4c6eb9d --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/commands/ListPatchesCommand.kt @@ -0,0 +1,24 @@ +package moe.crx.overport.commands + +import moe.crx.overport.cli.CliCommand +import moe.crx.overport.patching.PatchStore + +object ListPatchesCommand : CliCommand() { + override fun name() = "patches" + override fun description() = "Print list of available patches." + + override fun printHelp(): Boolean { + return false + } + + override suspend fun execute(args: List): Boolean { + println("Available patches:") + + PatchStore.all().forEach { + print("- ") + println(it.name) + } + + return true + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/commands/PatchApplicationCommand.kt b/overportcli/src/main/java/moe/crx/overport/commands/PatchApplicationCommand.kt new file mode 100644 index 0000000..f74a39c --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/commands/PatchApplicationCommand.kt @@ -0,0 +1,194 @@ +package moe.crx.overport.commands + +import kotlinx.coroutines.cancelAndJoin +import moe.crx.overport.cli.CliCommand +import moe.crx.overport.patches.PLATFORM_ICON_RESIZER +import moe.crx.overport.patching.PatchStore +import moe.crx.overport.patching.PatcherContext +import moe.crx.overport.utils.DesktopUtil.defaultWorkspace +import moe.crx.overport.utils.ImageIOIconResizer +import moe.crx.overport.utils.NameFormatter.DEFAULT_FORMAT +import moe.crx.overport.utils.PrintUtil.drawProgressBar +import moe.crx.overport.utils.PrintUtil.drawSpinner +import moe.crx.overport.utils.PrintUtil.updateLine +import moe.crx.overport.versions.VersionManager +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream + +object PatchApplicationCommand : CliCommand() { + override fun name() = "patch" + override fun description() = "Patch provided APK file." + + override fun printHelp(): Boolean { + println("usage: overport patch --input= [--output=]") + println(" [--output-name=] [--workspace=] [--patches=] [--version=]") + println() + println("--input=") + println("Specifies input APK file to patch.") + println() + println("--output=") + println("Specifies output APK file directory path.") + println("If not specified, uses same path as input file.") + println("If specified path is a file, uses it's parent directory.") + println() + println("--output-name=") + println("Specifies output APK file name format string.") + println("If not specified, uses name of input file but with \"output\" suffix appended to it.") + println("See more information about name format below.") + println() + println("--workspace=") + println("Specifies workspace directory path.") + println("If not specified, uses default one in user's home directory.") + println() + println("--patches=") + println("Specifies patches to apply. Value should be a semicolon-separated list of patch names.") + println("If not specified, uses every recommended patch available. Use \"overport patches\" to see available patches.") + println("You can also specify arguments for a specific patch using =.") + println() + println("--version=") + println("Specifies which overport version to use for patching.") + println("Value also could be \"experimental\" or \"latest\".") + println("If not specified, uses latest one. Use \"overport install\" to see available versions.") + println() + println("About file name format:") + println("File name format allows to specify pattern for automatic file name generation.") + println("Default file name format is $DEFAULT_FORMAT.") + println("Available format tags:") + println("{package} - application package name") + println("{label} - application label") + println("{code} - application version code") + println("{version} - application version name") + println("{overport} - overport version used for patching") + println("{filename} - input file name without extension") + println("{ext} - input file extension") + println("{filenameext} - input file name with extension") + return true + } + + private data class ParsedArguments( + var inputFile: String? = null, + var outputFile: String? = null, + var workspace: String? = null, + var patches: Map>? = null, + var nameFormat: String? = null, + var overportVersion: String? = null, + ) + + private fun scanArguments(args: List): ParsedArguments { + val parsed = ParsedArguments() + + val associated = args.associate { + it.substringBefore('=') to it.substringAfter('=', "") + } + + associated.forEach { (key, value) -> + when (key) { + "--input" -> parsed.inputFile = value + "--output" -> parsed.outputFile = value + "--workspace" -> parsed.workspace = value + "--patches" -> parsed.patches = value.split(';').map { splitted -> splitted.trim() }.associate { + it.substringBefore('=') to it.substringAfter('=', "").split(',').map { splitted -> splitted.trim() } + } + + "--output-name" -> parsed.nameFormat = value + "--version" -> parsed.overportVersion = value + } + } + + return parsed + } + + override suspend fun execute(args: List): Boolean { + val parsed = scanArguments(args) + + val inputFile = parsed.inputFile?.let { File(it) } ?: return false + + if (!inputFile.isFile) { + return false + } + + var outputDir = parsed.outputFile?.let { File(it) } ?: inputFile.parentFile + + if (outputDir.isFile) { + outputDir = outputDir.parentFile + } + + val workspace = parsed.workspace?.let { File(it) } ?: defaultWorkspace() + + val patches = parsed.patches ?: PatchStore.recommended().associate { it.name to listOf() } + + val nameFormat = parsed.nameFormat ?: DEFAULT_FORMAT + + val version = parsed.overportVersion ?: "latest" + + println("overport cli utility, version ${VersionManager.VERSION}") + println("Input APK: ${inputFile.absolutePath}") + println("Output directory: ${outputDir.absolutePath}") + println("Workspace directory: ${workspace.absolutePath}") + + PLATFORM_ICON_RESIZER = ImageIOIconResizer + val patcher = PatcherContext(version, workspace, inputFile.name, nameFormat) + + val waitingForLibraries = updateLine { + drawSpinner() + print("Preparing libraries...") + } + patcher.checkout() + waitingForLibraries.cancelAndJoin() + + println("Using overport version: ${patcher.overportVersion()}") + + val waitingForDecompilation = updateLine { + drawSpinner() + print("Decompiling APK...") + } + FileInputStream(inputFile).use { fis -> + patcher.prepare(fis) + } + waitingForDecompilation.cancelAndJoin() + + println("Decompiled app: ${patcher.appName()}, package ${patcher.appPackage()}, version ${patcher.appVersionName()}") + + val totalPatches = patches.size + var appliedPatches = 0 + var currentPatch = "" + val waitingForPatching = updateLine { + drawSpinner() + drawProgressBar(appliedPatches.toLong(), totalPatches.toLong()) + print("Applying patch $currentPatch... ") + print("($appliedPatches / $totalPatches)") + } + patcher.patch(patches) { index, patch -> + appliedPatches = index + currentPatch = patch.name + } + waitingForPatching.cancelAndJoin() + + outputDir.mkdirs() + + val outputFile = File(outputDir, patcher.outputFileName()) + + println("Output file: ${outputFile.absolutePath}") + + val waitingForCompilation = updateLine { + drawSpinner() + print("Compiling APK... ") + } + FileOutputStream(outputFile).use { fos -> + patcher.export(fos) + } + waitingForCompilation.cancelAndJoin() + + val waitingForCleaning = updateLine { + drawSpinner() + print("Cleaning up... ") + } + patcher.cleanup() + waitingForCleaning.cancelAndJoin() + + println("Patching successful.") + + return true + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/commands/VersionManagerCommand.kt b/overportcli/src/main/java/moe/crx/overport/commands/VersionManagerCommand.kt new file mode 100644 index 0000000..d80ff2e --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/commands/VersionManagerCommand.kt @@ -0,0 +1,152 @@ +package moe.crx.overport.commands + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import moe.crx.overport.cli.CliCommand +import moe.crx.overport.utils.DesktopUtil.defaultWorkspace +import moe.crx.overport.utils.PrintUtil.drawSpinner +import moe.crx.overport.utils.PrintUtil.updateLine +import moe.crx.overport.versions.OverportRelease +import moe.crx.overport.versions.VersionManager +import moe.crx.overport.versions.VersionManager.Companion.isIncompatible +import java.io.File + +object VersionManagerCommand : CliCommand() { + + val scope = CoroutineScope(Dispatchers.IO) + + override fun name() = "install" + + override fun description() = "Manage overport instances." + + override fun printHelp(): Boolean { + println("usage: overport install [--workspace=] [--version=]") + println() + println("--workspace=") + println("Specifies workspace directory path.") + println("If not specified, uses default one in user's home directory.") + println() + println("--version=") + println("Specifies which overport version to install or remove.") + println("Value also could be \"experimental\" or \"latest\".") + println("If not specified, prints all available or installed versions.") + println() + println("--remove") + println("Specifies if the version should be removed.") + println("If not specified, manager installs this version.") + return true + } + + private data class ParsedArguments( + var workspace: String? = null, + var overportVersion: String? = null, + var remove: Boolean = false, + ) + + private fun scanArguments(args: List): ParsedArguments { + val parsed = ParsedArguments() + + val associated = args.associate { + it.substringBefore('=') to it.substringAfter('=', "") + } + + associated.forEach { (key, value) -> + when (key) { + "--workspace" -> parsed.workspace = value + "--version" -> parsed.overportVersion = value + "--remove" -> parsed.remove = true + } + } + + return parsed + } + + fun printVersionList(listed: List, installed: List) { + listed.forEachIndexed { index, rel -> + val isInstalled = installed.firstOrNull { it.version == rel.version } != null + + print("[") + if (!isInstalled) { + print(" ") + } else { + print("x") + } + print("] ") + print(rel.version) + + if (index == 0) { + print(", latest") + } + if (rel.isExperimental) { + print(", experimental") + } + if (rel.isCustom) { + print(", custom") + } + + if (isInstalled) { + print(" [installed]") + } + if (isIncompatible(rel)) { + print(" [incompatible]") + } + + println() + } + } + + suspend fun printVersionsInfo(workspace: File) { + val manager = VersionManager(workspace) + + val waiting = updateLine { + drawSpinner() + print("Waiting for versions index...") + } + val available = scope.async { manager.available() }.await() + val installed = manager.installed() + + waiting.cancelAndJoin() + + if (available.isEmpty()) { + println("overport release index is not available. Check your internet connection.") + + if (installed.isEmpty()) { + println("You have no versions installed so you can't use patching yet.") + } else { + println("You have some versions installed that are available offline:") + printVersionList(installed, installed) + } + + return + } + + println("Available versions:") + printVersionList(available, installed) + + val noMoreAvailable = installed.filter { rel -> available.firstOrNull { rel.version == it.version } == null } + printVersionList(noMoreAvailable, installed) + } + + override suspend fun execute(args: List): Boolean { + val parsed = scanArguments(args) + + val workspace = parsed.workspace?.let { File(it) } + ?: defaultWorkspace() + + val version = parsed.overportVersion + + if (version == null) { + printVersionsInfo(workspace) + } else { + if (parsed.remove) { + VersionManager(workspace).uninstall(version) + } else { + VersionManager(workspace).checkout(version) + } + } + + return true + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/config/ApplicationMetaInfo.kt b/overportcli/src/main/java/moe/crx/overport/config/ApplicationMetaInfo.kt new file mode 100644 index 0000000..6253b12 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/config/ApplicationMetaInfo.kt @@ -0,0 +1,38 @@ +package moe.crx.overport.config + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import moe.crx.overport.utils.HttpUtil + +@Serializable +data class ApplicationMetaImage( + @SerialName("file_name") + var fileName: String, + @SerialName("id") + var id: String, + @SerialName("image_type") + var imageType: String, + @SerialName("uri") + var uri: String, +) + +@Serializable +data class ApplicationMetaInfo( + @SerialName("metaID") + var metaId: String, + @SerialName("displayName") + var displayName: String, + @SerialName("images") + var images: List, +) + +private val json = Json { ignoreUnknownKeys = true } + +fun getApplicationInfo(pkg: String): ApplicationMetaInfo? { + val url = "https://ovrp.crx.moe/images/by_package?package=$pkg" + return runCatching { + val string = String(HttpUtil.download(url, timeout = 20000)) + json.decodeFromString(string) + }.getOrNull() +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/config/ConfigGenerator.kt b/overportcli/src/main/java/moe/crx/overport/config/ConfigGenerator.kt new file mode 100644 index 0000000..3285f93 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/config/ConfigGenerator.kt @@ -0,0 +1,17 @@ +package moe.crx.overport.config + +import kotlinx.serialization.json.Json + +private val json = Json { ignoreUnknownKeys = true } + +fun OverportConfig.updateDatabase(fridaString: String) { + val config = runCatching { + json.decodeFromString(fridaString) + }.getOrNull() ?: return + + val products = config.interaction.parameters.skus + val assets = config.interaction.parameters.assets.map { OverportConfigAsset(it.id, it.path, it.type) } + + val application = OverportConfigApplication("__current__", products, assets) + database = listOf(application) +} diff --git a/overportcli/src/main/java/moe/crx/overport/config/FridaConfig.kt b/overportcli/src/main/java/moe/crx/overport/config/FridaConfig.kt new file mode 100644 index 0000000..69d26bc --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/config/FridaConfig.kt @@ -0,0 +1,28 @@ +package moe.crx.overport.config + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FridaAsset( + @SerialName("ID") + val id: Long = 0, + val path: String = "", + val type: String = "store" +) + +@Serializable +data class FridaParameters( + val assets: List = listOf(), + val skus: List = listOf() +) + +@Serializable +data class FridaInteraction( + val parameters: FridaParameters = FridaParameters(), +) + +@Serializable +data class FridaConfig( + val interaction: FridaInteraction = FridaInteraction(), +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/model/OverportConfig.kt b/overportcli/src/main/java/moe/crx/overport/config/OverportConfig.kt similarity index 68% rename from composeApp/src/commonMain/kotlin/moe/crx/ovrport/model/OverportConfig.kt rename to overportcli/src/main/java/moe/crx/overport/config/OverportConfig.kt index 671f5e1..cb3c4d9 100644 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/model/OverportConfig.kt +++ b/overportcli/src/main/java/moe/crx/overport/config/OverportConfig.kt @@ -1,7 +1,5 @@ -package moe.crx.ovrport.model +package moe.crx.overport.config -import kotlinx.serialization.EncodeDefault -import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -22,7 +20,7 @@ data class OverportConfigAsset( @SerialName("file_path") var filePath: String, @SerialName("asset_type") - var assetType: String = "store", + var assetType: String = "default", @SerialName("language") var language: OverportConfigLanguage = OverportConfigLanguage(), @SerialName("download_status") @@ -43,12 +41,28 @@ data class OverportConfigApplication( var assets: List, ) -@OptIn(ExperimentalSerializationApi::class) @Serializable -data class OverportConfigV1( +data class OverportPatchedInfo( + @SerialName("by") + var by: String, + @SerialName("with") + var with: String, + @SerialName("patches") + var patches: List, +) + +@Serializable +data class OverportConfig( @SerialName("version") - @EncodeDefault - var version: Int = 1, + var version: Int = 0, + @SerialName("disable_controller_offset") + var disableControllerOffset: Int = 0, + @SerialName("force_passthrough") + var forcePassthrough: Int = 0, + @SerialName("disable_space_warp") + var disableSpaceWarp: Int = 0, + @SerialName("verbose_logging") + var verboseLogging: Int = 0, @SerialName("suggested_texture_width") var suggestedTextureWidth: Int = 0, @SerialName("suggested_texture_height") @@ -62,5 +76,7 @@ data class OverportConfigV1( @SerialName("vibration_frequency_multiplier") var vibrationFrequencyMultiplier: Double = 1.0, @SerialName("database") - var database: List, + var database: List = listOf(), + @SerialName("patched") + var patched: OverportPatchedInfo? = null, ) \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/CleanUpFridaPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/CleanUpFridaPatch.kt new file mode 100644 index 0000000..d8bfbb1 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/CleanUpFridaPatch.kt @@ -0,0 +1,37 @@ +package moe.crx.overport.patches + +import moe.crx.overport.patching.Patch +import moe.crx.overport.utils.nameAttribute +import moe.crx.overport.utils.named +import moe.crx.overport.utils.takeNodesEach + +val PATCH_CLEAN_UP_FRIDA = Patch("patch_clean_up_frida") { + selectLibrary("libfrda.so") { + file.delete() + } + selectLibrary("libfrda.config.so") { + file.delete() + } + selectLibrary("libscript.so") { + file.delete() + } + + selectManifestJson { + takeNodesEach({ named("manifest") }) { + takeNodesEach({ named("application") }) { + takeNodesEach({ named("activity") }) { + nameAttribute()?.let { + selectSmali(it.replace('.', '/')) { + replace( + "const-string (\\w+), \\\"frda\\\"\\s+?invoke-static \\{\\1\\}, Ljava/lang/System;->loadLibrary\\(Ljava/lang/String;\\)V", + "" + ) + } + } + + this + } + } + } + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/CopyLibrariesPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/CopyLibrariesPatch.kt new file mode 100644 index 0000000..6ee05c7 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/CopyLibrariesPatch.kt @@ -0,0 +1,18 @@ +package moe.crx.overport.patches + +import moe.crx.overport.patching.Patch +import java.io.File + +val PATCH_COPY_LIBRARIES = Patch("patch_copy_libraries") { + selectWorkspace { + file.resolve("root/lib").listFiles()?.forEach { archDirectory -> + val arch = getLibraries().resolve("lib/${archDirectory.name}") + + if (arch.exists()) { + arch.listFiles().filter { it.name != "libOVRPlugin.so" }.forEach { library -> + library.copyTo(File(archDirectory, library.name), true) + } + } + } + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/CopyOVRPluginVrApiPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/CopyOVRPluginVrApiPatch.kt new file mode 100644 index 0000000..a178e3e --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/CopyOVRPluginVrApiPatch.kt @@ -0,0 +1,21 @@ +package moe.crx.overport.patches + +import moe.crx.overport.patching.Patch +import java.io.File + +val PATCH_COPY_OVRPLUGIN_VRAPI = Patch("patch_copy_ovrplugin_vrapi") { + selectWorkspace { + file.resolve("root/lib").listFiles()?.forEach { archDirectory -> + val arch = getLibraries().resolve("lib/${archDirectory.name}") + + if (arch.exists()) { + val hasVrApi = arch.listFiles().find { it.name.lowercase() == "libvrapi.so" } != null + if (hasVrApi) { + arch.listFiles().filter { it.name == "libOVRPlugin.so" }.forEach { library -> + library.copyTo(File(archDirectory, library.name), true) + } + } + } + } + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/DisableControllerOffsetPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/DisableControllerOffsetPatch.kt new file mode 100644 index 0000000..d00efd7 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/DisableControllerOffsetPatch.kt @@ -0,0 +1,9 @@ +package moe.crx.overport.patches + +import moe.crx.overport.patching.Patch + +val PATCH_DISABLE_CONTROLLER_OFFSET = Patch("patch_disable_controller_offset", false) { + selectConfig { + disableControllerOffset = 1 + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/DisableSpaceWarpPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/DisableSpaceWarpPatch.kt new file mode 100644 index 0000000..2dabd85 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/DisableSpaceWarpPatch.kt @@ -0,0 +1,9 @@ +package moe.crx.overport.patches + +import moe.crx.overport.patching.Patch + +val PATCH_DISABLE_SPACE_WARP = Patch("patch_disable_space_warp", false) { + selectConfig { + disableSpaceWarp = 1 + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/FixMinAndroidSdkPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/FixMinAndroidSdkPatch.kt new file mode 100644 index 0000000..c8a0814 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/FixMinAndroidSdkPatch.kt @@ -0,0 +1,59 @@ +package moe.crx.overport.patches + +import moe.crx.overport.patching.Patch +import moe.crx.overport.utils.named +import moe.crx.overport.utils.take +import moe.crx.overport.utils.takeAttributesEach +import moe.crx.overport.utils.takeNodesEach + +val PATCH_FIX_MIN_ANDROID_SDK = Patch("patch_fix_min_android_sdk") { + selectManifestJson { + takeNodesEach({ named("manifest") }) { + takeNodesEach({ named("uses-sdk") }) { + takeAttributesEach({ named("minSdkVersion") }) { + take("data") { 29 } + } + } + } + } + + 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( + "(if-lt \\w+?, \\w+?, :\\S+\\s*?)?const-string \\w+?, \\\"vibrator_manager\\\".*?iput-object \\w+?, \\w+?, Lcom/epicgames/unreal/GameActivity;->\\w+?:Landroid/os/Vibrator;", + "" + ) + } + + selectSmali("com/unity3d/player/PlatformSupport") { + replace("(\\.field static final RED_VELVET_CAKE_SUPPORT:Z)( = true)?( = false)?", "$1 = false") + replace("(\\.field static final SNOW_CONE_SUPPORT:Z)( = true)?( = false)?", "$1 = false") + replace("sput-boolean v1, Lcom\\/unity3d\\/player\\/PlatformSupport;->RED_VELVET_CAKE_SUPPORT:Z", "") + replace("sput-boolean v1, Lcom\\/unity3d\\/player\\/PlatformSupport;->SNOW_CONE_SUPPORT:Z", "") + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/FixUnrealCrashPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/FixUnrealCrashPatch.kt new file mode 100644 index 0000000..b9ad178 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/FixUnrealCrashPatch.kt @@ -0,0 +1,20 @@ +package moe.crx.overport.patches + +import moe.crx.overport.patching.Patch + +val PATCH_FIX_UNREAL_CRASH = Patch("patch_fix_unreal_crash") { + createSmali("com/unity3d/player/UnityPlayer") { + if (readText().isBlank()) { + 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;" + ) + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/ForcePassthroughPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/ForcePassthroughPatch.kt new file mode 100644 index 0000000..7b01e87 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/ForcePassthroughPatch.kt @@ -0,0 +1,9 @@ +package moe.crx.overport.patches + +import moe.crx.overport.patching.Patch + +val PATCH_FORCE_PASSTHROUGH = Patch("patch_force_passthrough", false) { + selectConfig { + forcePassthrough = 1 + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/GenerateConfigPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/GenerateConfigPatch.kt new file mode 100644 index 0000000..4bd4356 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/GenerateConfigPatch.kt @@ -0,0 +1,14 @@ +package moe.crx.overport.patches + +import moe.crx.overport.config.updateDatabase +import moe.crx.overport.patching.Patch + +val PATCH_GENERATE_CONFIG = Patch("patch_generate_config") { + selectLibrary("libfrda.config.so") { + readText().let { fridaJson -> + selectConfig { + updateDatabase(fridaJson) + } + } + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/LauncherEntryPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/LauncherEntryPatch.kt new file mode 100644 index 0000000..892d9f7 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/LauncherEntryPatch.kt @@ -0,0 +1,55 @@ +package moe.crx.overport.patches + +import com.reandroid.json.JSONArray +import com.reandroid.json.JSONObject +import moe.crx.overport.patching.Patch +import moe.crx.overport.utils.* + +fun createAction(value: String): JSONObject { + return JSONObject().put("node_type", "element").put("name", "action").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) + ) + ) +} + +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) + ) + ) +} + +val PATCH_LAUNCHER_ENTRY = Patch("patch_launcher_entry") { + selectManifestJson { + takeNodesEach({ named("manifest") }) { + takeNodesEach({ named("application") }) { + takeNodesEach({ named("activity") }) { + takeNodesEach({ named("intent-filter") }) { + val hasIntent = elem("nodes").elemEach { named("category") }.map { + when (it.nameAttribute()) { + "com.oculus.intent.category.VR" -> true + "android.intent.category.LAUNCHER" -> true + else -> false + } + }.reduceOrNull { l, r -> l || r } ?: false + + takeNodes { + if (!hasIntent) this else { + JSONArray().put(createAction("android.intent.action.MAIN")) + .put(createCategory("android.intent.category.LAUNCHER")) + .put(createCategory("com.oculus.intent.category.VR")) + .put(createCategory("com.yvr.intent.category.VR")) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/MarkAllowBackupPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/MarkAllowBackupPatch.kt new file mode 100644 index 0000000..b4a385d --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/MarkAllowBackupPatch.kt @@ -0,0 +1,27 @@ +package moe.crx.overport.patches + +import com.reandroid.json.JSONObject +import moe.crx.overport.patching.Patch +import moe.crx.overport.utils.* + +fun createAllowBackup(): JSONObject { + return JSONObject().put("name", "allowBackup").put("id", 16843392) + .put("uri", "http://schemas.android.com/apk/res/android").put("prefix", "android").put("value_type", "BOOLEAN") + .put("data", true) +} + +val PATCH_MARK_ALLOW_BACKUP = Patch("patch_mark_allow_backup") { + selectManifestJson { + takeNodesEach({ named("manifest") }) { + takeNodesEach({ named("application") }) { + takeAttributes { + if (elemEach { named("allowBackup") }.isEmpty()) { + this?.put(createAllowBackup()) + } + + this + } + } + } + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/MarkAsDebuggablePatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/MarkAsDebuggablePatch.kt new file mode 100644 index 0000000..e6911b8 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/MarkAsDebuggablePatch.kt @@ -0,0 +1,27 @@ +package moe.crx.overport.patches + +import com.reandroid.json.JSONObject +import moe.crx.overport.patching.Patch +import moe.crx.overport.utils.* + +fun createDebuggable(): JSONObject { + return JSONObject().put("name", "debuggable").put("id", 16842767) + .put("uri", "http://schemas.android.com/apk/res/android").put("prefix", "android").put("value_type", "BOOLEAN") + .put("data", true) +} + +val PATCH_MARK_AS_DEBUGGABLE = Patch("patch_mark_as_debuggable") { + selectManifestJson { + takeNodesEach({ named("manifest") }) { + takeNodesEach({ named("application") }) { + takeAttributes { + if (elemEach { named("debuggable") }.isEmpty()) { + this?.put(createDebuggable()) + } + + this + } + } + } + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/MetaXRAudioPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/MetaXRAudioPatch.kt new file mode 100644 index 0000000..9218597 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/MetaXRAudioPatch.kt @@ -0,0 +1,27 @@ +package moe.crx.overport.patches + +import moe.crx.overport.patching.Patch + +val PATCH_META_XR_AUDIO = Patch("patch_meta_xr_audio") { + selectLibrary("libMetaXRAudioWwise.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" + ) + } + + // TODO make a more universal patch? + selectLibrary("libMetaXRAudioWwise.so") { + replaceHex( + "21 48 26 91 E0 03 14 AA 08 19 40 F9 00 01 3F D6 A0 07 00 B4 88 02 40 F9", + "21 48 26 91 E0 03 14 AA E0 03 1F AA 1F 20 03 D5 A0 07 00 B4 88 02 40 F9" + ) + } + + selectLibrary("libMetaXRAudioUnity.so") { + replaceHex( + "E0 03 14 AA 08 19 40 F9 00 01 3F D6 A0 07 00 B4 88 02 40 F9 E1 03 00 AA", + "E0 03 14 AA 08 19 40 F9 E0 03 1F AA 44 00 00 14 88 02 40 F9 E1 03 00 AA" + ) + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/OculusUnityPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/OculusUnityPatch.kt new file mode 100644 index 0000000..1616581 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/OculusUnityPatch.kt @@ -0,0 +1,11 @@ +package moe.crx.overport.patches + +import moe.crx.overport.patching.Patch + +val PATCH_OCULUS_UNITY = Patch("patch_oculus_unity") { + selectSmali("com/unity/oculus/OculusUnity") { + replace( + "(getIsOnOculusHardware\\(\\)Z.*?)return (\\w+)", "$1\nconst/4 $2, 0x1\nreturn $2" + ) + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/OculusUnrealPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/OculusUnrealPatch.kt new file mode 100644 index 0000000..e11321d --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/OculusUnrealPatch.kt @@ -0,0 +1,17 @@ +package moe.crx.overport.patches + +import moe.crx.overport.patching.Patch + +val PATCH_OCULUS_UNREAL = Patch("patch_oculus_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\"" + ) + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/RemoveLocalizedNamesPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/RemoveLocalizedNamesPatch.kt new file mode 100644 index 0000000..3832bb5 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/RemoveLocalizedNamesPatch.kt @@ -0,0 +1,34 @@ +package moe.crx.overport.patches + +import com.reandroid.json.JSONArray +import com.reandroid.json.JSONObject +import moe.crx.overport.patching.Patch +import moe.crx.overport.utils.elem +import moe.crx.overport.utils.take +import moe.crx.overport.utils.takeEach + +val PATCH_REMOVE_LOCALIZED_NAMES = Patch("patch_remove_localized_names") { + selectResourcesJson { + take("packages") { + takeEach { + take("specs") { + takeEach { + take("types") { + takeEach { + val configLanguage = elem("config").elem("language") + + take("entries") { + takeEach { + this.takeIf { + elem("entry_name") != "app_name" || configLanguage == null + } + } + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/RemoveUnrealForceQuitPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/RemoveUnrealForceQuitPatch.kt new file mode 100644 index 0000000..0bbd46f --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/RemoveUnrealForceQuitPatch.kt @@ -0,0 +1,15 @@ +package moe.crx.overport.patches + +import moe.crx.overport.patching.Patch + +val PATCH_REMOVE_UNREAL_FORCE_QUIT = Patch("patch_remove_unreal_force_quit", false) { + selectSmali( + "com/epicgames/ue4/GameActivity", + "com/epicgames/unreal/GameActivity", + ) { + replace( + "(\\.method public AndroidThunkJava_ForceQuit\\(\\)V.*?)(invoke-static \\{\\w\\d+?\\}, Ljava\\/lang\\/System;->exit\\(I\\)V)(.*?\\.end method)", + "$1$3" + ) + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/RemoveUsesLibraryPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/RemoveUsesLibraryPatch.kt new file mode 100644 index 0000000..7787e6b --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/RemoveUsesLibraryPatch.kt @@ -0,0 +1,16 @@ +package moe.crx.overport.patches + +import moe.crx.overport.patching.Patch +import moe.crx.overport.utils.named +import moe.crx.overport.utils.takeNodesEach + +val PATCH_REMOVE_USES_LIBRARY = Patch("patch_remove_uses_library") { + selectManifestJson { + takeNodesEach({ named("manifest") }) { + takeNodesEach({ named("application") }) { + takeNodesEach({ named("uses-library") }) { null } + takeNodesEach({ named("uses-native-library") }) { null } + } + } + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/ReplaceIconLabelPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/ReplaceIconLabelPatch.kt new file mode 100644 index 0000000..107880a --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/ReplaceIconLabelPatch.kt @@ -0,0 +1,118 @@ +package moe.crx.overport.patches + +import com.reandroid.json.JSONObject +import moe.crx.overport.config.getApplicationInfo +import moe.crx.overport.patching.Patch +import moe.crx.overport.utils.named +import moe.crx.overport.utils.takeAttributes +import moe.crx.overport.utils.takeAttributesEach +import moe.crx.overport.utils.takeNodesEach +import java.util.* + +interface IconResizer { + fun resize(bytes: ByteArray, width: Int, height: Int): ByteArray +} + +fun createLabel(label: String): JSONObject { + return JSONObject().put("name", "label").put("id", 16842753) + .put("uri", "http://schemas.android.com/apk/res/android").put("prefix", "android").put("value_type", "STRING") + .put("data", label) +} + +fun createIconReference(reference: Int): JSONObject { + return JSONObject().put("name", "icon").put("id", 16842754) + .put("uri", "http://schemas.android.com/apk/res/android").put("prefix", "android") + .put("value_type", "REFERENCE") + .put("data", reference) +} + +var PLATFORM_ICON_RESIZER: IconResizer? = null + +val PATCH_REPLACE_ICON_LABEL = Patch("patch_replace_icon_label") { arguments -> + var reference = 0 + var iconDownloaded = false + var gameTitle: String? = null + + createFile("root/res/drawable/custom_icon_ovrp.png") { + val info = getApplicationInfo(applicationPackage()) + + gameTitle = info?.displayName + + val image = info?.run { + val associated = images.associateBy { it.imageType } + if (arguments.firstOrNull() == "cover") { + associated["APP_IMG_COVER_SQUARE"] ?: associated["APP_IMG_ICON"] + } else { + associated["APP_IMG_ICON"] ?: associated["APP_IMG_COVER_SQUARE"] + } + } + + val bytes = image?.uri?.let { Base64.getDecoder().decode(it) } + var resizedBytes = bytes?.let { PLATFORM_ICON_RESIZER?.resize(it, 512, 512) } ?: bytes + + if (resizedBytes != null) { + iconDownloaded = runCatching { writeBytes(resizedBytes) }.isSuccess + } + } + + if (iconDownloaded) { + selectPackageBlock { + getOrCreate("", "drawable", "custom_icon_ovrp").apply { + valueAsString = "res/drawable/custom_icon_ovrp.png" + reference = resourceId + } + } + + selectManifestJson { + takeNodesEach({ named("manifest") }) { + takeNodesEach({ named("application") }) { + takeNodesEach({ named("activity") }) { + takeAttributesEach({ named("icon") }) { + null + } + } + takeNodesEach({ named("activity-alias") }) { + takeAttributesEach({ named("icon") }) { + null + } + } + + takeAttributesEach({ named("icon") }) { + null + } + + takeAttributes { + this?.put(createIconReference(reference)) + } + } + } + } + } + + gameTitle?.let { gameTitle -> + selectManifestJson { + takeNodesEach({ named("manifest") }) { + takeNodesEach({ named("application") }) { + takeNodesEach({ named("activity") }) { + takeAttributesEach({ named("label") }) { + null + } + } + takeNodesEach({ named("activity-alias") }) { + takeAttributesEach({ named("label") }) { + null + } + } + + takeAttributesEach({ named("label") }) { + null + } + + takeAttributes { + this?.put(createLabel(gameTitle)) + } + } + } + } + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patches/VRMetadataPatch.kt b/overportcli/src/main/java/moe/crx/overport/patches/VRMetadataPatch.kt new file mode 100644 index 0000000..972cf97 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patches/VRMetadataPatch.kt @@ -0,0 +1,40 @@ +package moe.crx.overport.patches + +import com.reandroid.json.JSONArray +import com.reandroid.json.JSONObject +import moe.crx.overport.patching.Patch +import moe.crx.overport.utils.nameAttribute +import moe.crx.overport.utils.named +import moe.crx.overport.utils.takeNodes +import moe.crx.overport.utils.takeNodesEach + +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) + ) + ) +} + +val PATCH_VR_METADATA = Patch("patch_vr_metadata") { + selectManifestJson { + takeNodesEach({ named("manifest") }) { + takeNodesEach({ named("application") }) { + takeNodesEach({ named("meta-data") }) { + if (nameAttribute() == "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")) + } + } + } + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patching/FileTransformer.kt b/overportcli/src/main/java/moe/crx/overport/patching/FileTransformer.kt new file mode 100644 index 0000000..2f14506 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patching/FileTransformer.kt @@ -0,0 +1,63 @@ +package moe.crx.overport.patching + +import com.reandroid.json.JSONObject +import java.io.File + +class FileTransformer(val file: File) { + fun replace(input: String, output: String) { + file.writeText(Regex(input, RegexOption.DOT_MATCHES_ALL).replace(file.readText(), output)) + } + + fun readText(): String { + return file.readText() + } + + fun append(line: String) { + file.appendText(line) + } + + fun readJson(): JSONObject? { + return runCatching { JSONObject(file.readText()) }.getOrNull() + } + + fun writeText(text: String) { + file.writeText(text) + } + + fun writeBytes(bytes: ByteArray) { + file.writeBytes(bytes) + } + + fun writeJson(json: JSONObject) { + writeText(json.toString()) + } + + fun replaceHex(input: String, output: String) { + val bytes = file.readBytes() + val inputBytes = input.split(' ') + + for (i in 0..= inputBytes.size) { + break + } + + if (inputBytes[checkIndex] != "??" && bytes[j].toUByte() != inputBytes[checkIndex].toUByte(16)) { + matches = false + break + } + } + + if (matches) { + val replaced = bytes.slice(0..) -> Unit, +) diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/Patch.kt b/overportcli/src/main/java/moe/crx/overport/patching/PatchExecutor.kt similarity index 67% rename from composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/Patch.kt rename to overportcli/src/main/java/moe/crx/overport/patching/PatchExecutor.kt index b243595..c8baf99 100644 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/Patch.kt +++ b/overportcli/src/main/java/moe/crx/overport/patching/PatchExecutor.kt @@ -1,66 +1,26 @@ -package moe.crx.ovrport.patch +package moe.crx.overport.patching +import com.reandroid.apk.ApkModule +import com.reandroid.apk.ApkModuleJsonDecoder +import com.reandroid.arsc.chunk.PackageBlock import com.reandroid.json.JSONObject +import kotlinx.serialization.json.Json +import moe.crx.overport.config.OverportConfig 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)) +class PatchExecutor(private val librariesDir: File, private val workingDir: File, private val apkModule: ApkModule) { + companion object { + private val json = Json { ignoreUnknownKeys = true } } - fun readText(): String { - return file.readText() + fun getLibraries(): File { + return librariesDir } - fun append(line: String) { - file.appendText(line) + fun applicationPackage(): String { + return apkModule.androidManifest.packageName } - 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..= inputBytes.size) { - break - } - - if (inputBytes[checkIndex] != "??" && bytes[j].toUByte() != inputBytes[checkIndex].toUByte(16)) { - matches = false - break - } - } - - if (matches) { - val replaced = bytes.slice(0.. Unit) { workingDir.run { if (exists()) { @@ -77,6 +37,14 @@ class PatchExecutor(private val workingDir: File) { } } + fun createFile(fileName: String, block: FileTransformer.() -> Unit) { + workingDir.resolve(fileName).run { + parentFile?.mkdirs() + createNewFile() + FileTransformer(this).block() + } + } + fun selectResources(block: FileTransformer.() -> Unit) { selectFile("resources/resources.arsc.json", block) } @@ -85,6 +53,44 @@ class PatchExecutor(private val workingDir: File) { selectFile("AndroidManifest.xml.json", block) } + fun selectResourcesJson(block: JSONObject?.() -> Unit) { + selectResources { + val json = readJson() + block(json) + json?.let { writeJson(it) } + } + } + + fun selectManifestJson(block: JSONObject?.() -> Unit) { + selectManifest { + val json = readJson() + block(json) + json?.let { writeJson(it) } + } + } + + fun selectPackageBlock(block: PackageBlock.() -> Unit) { + apkModule.tableBlock.getPackages(apkModule.androidManifest.packageName).forEach { + block(it) + } + ApkModuleJsonDecoder(apkModule).decodeResourceTable(workingDir) + } + + fun selectConfig(block: OverportConfig.() -> Unit) { + createLibrary("liboverport.config.so") { + val config = runCatching { + json.decodeFromString(readText()) + }.getOrElse { + OverportConfig(version = 1) + } + + block(config) + + val jsonString = json.encodeToString(config) + writeText(jsonString) + } + } + fun selectLibrary(fileName: String, block: FileTransformer.() -> Unit) { listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64").forEach { arch -> val archDirectory = workingDir.resolve("root/lib/$arch") diff --git a/overportcli/src/main/java/moe/crx/overport/patching/PatchStore.kt b/overportcli/src/main/java/moe/crx/overport/patching/PatchStore.kt new file mode 100644 index 0000000..2e4388f --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patching/PatchStore.kt @@ -0,0 +1,50 @@ +package moe.crx.overport.patching + +import moe.crx.overport.patches.* + +object PatchStore { + private val PATCHES = mutableMapOf() + + private fun register(patch: Patch) { + if (PATCHES[patch.name] != null) { + throw IllegalStateException("Patch with name ${patch.name} is already registered.") + } + + PATCHES[patch.name] = patch + } + + fun all(): List { + return PATCHES.values.toList() + } + + fun get(name: String): Patch? { + return PATCHES[name] + } + + fun recommended(): List { + return all().filter { it.isRecommended } + } + + init { + register(PATCH_COPY_LIBRARIES) + register(PATCH_COPY_OVRPLUGIN_VRAPI) + register(PATCH_REPLACE_ICON_LABEL) + register(PATCH_FIX_MIN_ANDROID_SDK) + register(PATCH_GENERATE_CONFIG) + register(PATCH_REMOVE_LOCALIZED_NAMES) + register(PATCH_CLEAN_UP_FRIDA) + register(PATCH_OCULUS_UNITY) + register(PATCH_OCULUS_UNREAL) + register(PATCH_VR_METADATA) + register(PATCH_LAUNCHER_ENTRY) + register(PATCH_REMOVE_USES_LIBRARY) + register(PATCH_FIX_UNREAL_CRASH) + register(PATCH_META_XR_AUDIO) + register(PATCH_MARK_AS_DEBUGGABLE) + register(PATCH_MARK_ALLOW_BACKUP) + register(PATCH_REMOVE_UNREAL_FORCE_QUIT) + register(PATCH_FORCE_PASSTHROUGH) + register(PATCH_DISABLE_SPACE_WARP) + register(PATCH_DISABLE_CONTROLLER_OFFSET) + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/patching/PatcherContext.kt b/overportcli/src/main/java/moe/crx/overport/patching/PatcherContext.kt new file mode 100644 index 0000000..686af08 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/patching/PatcherContext.kt @@ -0,0 +1,266 @@ +package moe.crx.overport.patching + +import com.android.apksig.ApkSigner +import com.reandroid.apk.* +import com.reandroid.archive.ArchiveFile +import com.reandroid.archive.FileInputSource +import com.reandroid.archive.io.ZipFileInput +import moe.crx.overport.config.OverportPatchedInfo +import moe.crx.overport.utils.NameFormatter +import moe.crx.overport.versions.VersionManager +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.ByteArrayOutputStream +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption.REPLACE_EXISTING + +class PatcherContext( + private var overportVersion: String, + val patcherDirectory: File, + val inputFileName: String, + val nameFormat: String, +) { + val dataDir: File = patcherDirectory.resolve("dec") + + val namespace: String = "working" + val inputApk: File = dataDir.resolve("$namespace.apk") + val outputApk: File = dataDir.resolve("$namespace.output.apk") + + val workingDir: File = dataDir.resolve(namespace) + var workingApk: ApkModule? = null + + fun isApkLoaded(): Boolean { + return workingApk != null + } + + fun overportVersion(): String { + return overportVersion + } + + fun outputFileName(): String { + return NameFormatter.format(nameFormat, this) + } + + fun checkout() { + var successful = VersionManager(patcherDirectory).checkout(overportVersion) + + if (successful == null) { + successful = VersionManager(patcherDirectory).checkout("latest") + } + + checkNotNull(successful) + overportVersion = successful + } + + fun prepare(stream: InputStream) { + dataDir.mkdirs() + + workingDir.deleteRecursively() + inputApk.delete() + outputApk.delete() + + Files.copy(stream, inputApk.toPath(), REPLACE_EXISTING) + + val zipInput = ZipFileInput(inputApk) + val apk = runCatching { + val archive = ArchiveFile(zipInput) + ApkModule("base", archive.createZipEntryMap()).apply { + apkSignatureBlock = archive.apkSignatureBlock + setCloseable(archive) + } + }.onFailure { + zipInput.close() + }.getOrThrow() + + 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 patch( + args: Map>, + callback: (Int, Patch) -> Unit = { _, _ -> } + ) { + val librariesDir = VersionManager(patcherDirectory).getLibraries(overportVersion) + + val executor = PatchExecutor(librariesDir, workingDir, apk()) + + val patches = args.keys.mapNotNull { PatchStore.get(it) } + + patches.forEachIndexed { index, patch -> + callback(index, patch) + patch.executor(executor, args[patch.name] ?: listOf()) + } + + executor.selectConfig { + patched = OverportPatchedInfo( + by = VersionManager.VERSION, + with = overportVersion, + patches = patches.map { it.name } + ) + } + } + + fun export(stream: OutputStream) { + 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() + .plus(".keystore") + .let { signatureDir.resolve(it) } + .let { SignatureStore.getSignature(it) } + + val tempFile = outputApk + .parentFile + ?.resolve(outputApk.name + ".temp") + ?.let { + it.delete() + outputApk.copyTo(it) + } + + ApkSigner + .Builder(listOf(signature)) + .setAlignFileSize(true) + .setMinSdkVersion(29) + .setInputApk(tempFile) + .setOutputApk(outputApk) + .build() + .sign() + + tempFile?.delete() + + Files.copy(outputApk.toPath(), stream) + } + + fun cleanup() { + workingDir.deleteRecursively() + workingApk?.close() + workingApk = null + inputApk.delete() + outputApk.delete() + } + + private fun apk(): ApkModule { + val apk = workingApk + checkNotNull(apk) + return apk + } + + fun appPackage(): String { + return apk().androidManifest.packageName + } + + fun appVersionCode(): String { + return apk().androidManifest.versionCode.toString() + } + + fun appVersionName(): String { + return apk().androidManifest.versionName + ?: apk().androidManifest.versionCode.toString() + } + + fun appName(): String { + return apk().androidManifest.applicationLabelString + ?: apk().tableBlock + .getEntries(workingApk?.androidManifest?.applicationLabelReference ?: 0) + .asSequence() + .first() + .valueAsString + } + + fun appIcon(): File? { + val iconId = apk().androidManifest.iconResourceId.takeIf { it != 0 } + ?: apk().androidManifest.roundIconResourceId.takeIf { it != 0 } + ?: return null + + val result = apk().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 + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/SignatureStore.kt b/overportcli/src/main/java/moe/crx/overport/patching/SignatureStore.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/SignatureStore.kt rename to overportcli/src/main/java/moe/crx/overport/patching/SignatureStore.kt index 66d17e0..0affc5c 100644 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/patch/SignatureStore.kt +++ b/overportcli/src/main/java/moe/crx/overport/patching/SignatureStore.kt @@ -1,4 +1,4 @@ -package moe.crx.ovrport.patch +package moe.crx.overport.patching import com.android.apksig.ApkSigner.SignerConfig import com.android.apksig.KeyConfig diff --git a/overportcli/src/main/java/moe/crx/overport/utils/DesktopUtil.kt b/overportcli/src/main/java/moe/crx/overport/utils/DesktopUtil.kt new file mode 100644 index 0000000..9f9cbae --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/utils/DesktopUtil.kt @@ -0,0 +1,9 @@ +package moe.crx.overport.utils + +import java.io.File + +object DesktopUtil { + fun defaultWorkspace(): File { + return File(System.getProperty("user.home")).resolve("overport/cache") + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/utils/HttpUtil.kt b/overportcli/src/main/java/moe/crx/overport/utils/HttpUtil.kt similarity index 67% rename from composeApp/src/commonMain/kotlin/moe/crx/ovrport/utils/HttpUtil.kt rename to overportcli/src/main/java/moe/crx/overport/utils/HttpUtil.kt index 41b68e1..7dd1258 100644 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/utils/HttpUtil.kt +++ b/overportcli/src/main/java/moe/crx/overport/utils/HttpUtil.kt @@ -1,11 +1,13 @@ -package moe.crx.ovrport.utils +package moe.crx.overport.utils import java.net.HttpURLConnection import java.net.URI object HttpUtil { - fun download(url: String): ByteArray { + fun download(url: String, timeout: Int = 5000): ByteArray { val uri = URI(url).toURL().openConnection() as HttpURLConnection + uri.readTimeout = timeout + uri.connectTimeout = 5000 val bytes = uri.errorStream?.use { it.readBytes() diff --git a/overportcli/src/main/java/moe/crx/overport/utils/ImageIOIconResizer.kt b/overportcli/src/main/java/moe/crx/overport/utils/ImageIOIconResizer.kt new file mode 100644 index 0000000..8fafaff --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/utils/ImageIOIconResizer.kt @@ -0,0 +1,29 @@ +package moe.crx.overport.utils + +import moe.crx.overport.patches.IconResizer +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import javax.imageio.ImageIO + +object ImageIOIconResizer : IconResizer { + override fun resize(bytes: ByteArray, width: Int, height: Int): ByteArray { + val original = ImageIO.read(ByteArrayInputStream(bytes)) + val resized = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + + resized.createGraphics().apply { + setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC) + setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY) + setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + + drawImage(original, 0, 0, width, height, null) + dispose() + } + + val baos = ByteArrayOutputStream() + ImageIO.write(resized, "png", baos) + + return baos.toByteArray() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/utils/JsonUtil.kt b/overportcli/src/main/java/moe/crx/overport/utils/JsonUtil.kt similarity index 51% rename from composeApp/src/commonMain/kotlin/moe/crx/ovrport/utils/JsonUtil.kt rename to overportcli/src/main/java/moe/crx/overport/utils/JsonUtil.kt index 9bd1851..d9a71cf 100644 --- a/composeApp/src/commonMain/kotlin/moe/crx/ovrport/utils/JsonUtil.kt +++ b/overportcli/src/main/java/moe/crx/overport/utils/JsonUtil.kt @@ -1,4 +1,4 @@ -package moe.crx.ovrport.utils +package moe.crx.overport.utils import com.reandroid.json.JSONArray import com.reandroid.json.JSONObject @@ -22,51 +22,39 @@ inline fun JSONArray?.take(index: Int, block: T?.() -> T? = { this } } inline fun JSONObject?.takeEach( - condition: T?.() -> Boolean = { true }, - block: T?.() -> T? = { this } + condition: T?.() -> Boolean = { true }, block: T?.() -> T? = { this } ): JSONObject? { - this?.keys()?.asSequence()?.toList() - ?.forEach { i -> - elem(i).let { - if (condition(it)) { - block(it)?.let { value -> put(i, value) } - ?: remove(i) - } + this?.keys()?.asSequence()?.toList()?.forEach { i -> + elem(i).let { + if (condition(it)) { + block(it)?.let { value -> put(i, value) } ?: remove(i) } } + } return this } inline fun JSONArray?.takeEach( - condition: T?.() -> Boolean = { true }, - block: T?.() -> T? = { this } + condition: T?.() -> Boolean = { true }, block: T?.() -> T? = { this } ): JSONArray? { - this?.length()?.let { (it - 1).downTo(0) } - ?.forEach { i -> - elem(i).let { - if (condition(it)) { - block(it)?.let { value -> put(i, value) } - ?: remove(i) - } + this?.length()?.let { (it - 1).downTo(0) }?.forEach { i -> + elem(i).let { + if (condition(it)) { + block(it)?.let { value -> put(i, value) } ?: remove(i) } } + } return this } inline fun JSONObject?.elemEach(condition: T?.() -> Boolean = { true }): List { - return this?.keys()?.asSequence()?.toList() - ?.mapNotNull { i -> elem(i) } - ?.filter { condition(it) } - ?.toList() + return this?.keys()?.asSequence()?.toList()?.mapNotNull { i -> elem(i) }?.filter { condition(it) }?.toList() ?: listOf() } inline fun JSONArray?.elemEach(condition: T?.() -> Boolean = { true }): List { - return this?.length()?.let { (it - 1).downTo(0) } - ?.mapNotNull { i -> elem(i) } - ?.filter { condition(it) } - ?.toList() - ?: listOf() + return this?.length()?.let { (it - 1).downTo(0) }?.mapNotNull { i -> elem(i) }?.filter { condition(it) } + ?.toList() ?: listOf() } diff --git a/overportcli/src/main/java/moe/crx/overport/utils/ManifestUtil.kt b/overportcli/src/main/java/moe/crx/overport/utils/ManifestUtil.kt new file mode 100644 index 0000000..d129418 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/utils/ManifestUtil.kt @@ -0,0 +1,36 @@ +package moe.crx.overport.utils + +import com.reandroid.json.JSONArray +import com.reandroid.json.JSONObject + +fun JSONObject?.named(name: String?): Boolean { + return elem("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?.nameAttribute(): String? { + return elem("attributes").elemEach { named("name") }.firstOrNull().elem("data") +} diff --git a/overportcli/src/main/java/moe/crx/overport/utils/NameFormatter.kt b/overportcli/src/main/java/moe/crx/overport/utils/NameFormatter.kt new file mode 100644 index 0000000..988b0c5 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/utils/NameFormatter.kt @@ -0,0 +1,20 @@ +package moe.crx.overport.utils + +import moe.crx.overport.patching.PatcherContext + +object NameFormatter { + + const val DEFAULT_FORMAT = "{filename}.output.{ext}" + + fun format(format: String, context: PatcherContext): String { + return (format.takeIf { it.isNotBlank() } ?: DEFAULT_FORMAT) + .replace("{package}", context.appPackage()) + .replace("{label}", context.appName()) + .replace("{code}", context.appVersionCode()) + .replace("{version}", context.appVersionName()) + .replace("{overport}", context.overportVersion()) + .replace("{filename}", context.inputFileName.substringBeforeLast('.')) + .replace("{ext}", context.inputFileName.substringAfterLast('.')) + .replace("{filenameext}", context.inputFileName) + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/utils/PrintUtil.kt b/overportcli/src/main/java/moe/crx/overport/utils/PrintUtil.kt new file mode 100644 index 0000000..1f82298 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/utils/PrintUtil.kt @@ -0,0 +1,68 @@ +package moe.crx.overport.utils + +import kotlinx.coroutines.* +import kotlin.math.max + +object PrintUtil { + const val PROGRESS_LENGTH = 20L + var progressTick = 0 + val scope = CoroutineScope(Dispatchers.IO) + + fun updateLine(block: () -> Unit): Job { + return scope.launch { + while (isActive) { + drawLine(block) + delay(300) + } + } + } + + fun drawLine(block: () -> Unit) { + block() + progressTick++ + print("\r") + } + + fun drawSpinner() { + val progressSpinner = when (progressTick % 4) { + 0 -> '|' + 1 -> '/' + 2 -> '-' + 3 -> '\\' + else -> '*' + } + + print(progressSpinner) + print(" ") + } + + fun drawProgressBar(done: Long, total: Long?) { + print("[") + + val doneChars = if (total != null) done * PROGRESS_LENGTH / total else 0 + (1..doneChars).forEach { _ -> + print('#') + } + if (doneChars != PROGRESS_LENGTH) { + val plusPosition = progressTick % (PROGRESS_LENGTH - doneChars) + (1..plusPosition).forEach { _ -> + print(' ') + } + print('+') + (1..PROGRESS_LENGTH - 1 - plusPosition - doneChars).forEach { _ -> + print(' ') + } + } + + print("] ") + } + + fun fspace(): String { + return " " + } + + fun uspace(key: Any, list: Collection): String { + val max = list.map { it.toString().length }.maxOfOrNull { it } ?: 0 + return " ".repeat(max(max - key.toString().length, 0) + 4) + } +} \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/versions/OverportRelease.kt b/overportcli/src/main/java/moe/crx/overport/versions/OverportRelease.kt new file mode 100644 index 0000000..2d8bf21 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/versions/OverportRelease.kt @@ -0,0 +1,24 @@ +package moe.crx.overport.versions + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class OverportRelease( + @SerialName("version") + var version: String, + @SerialName("app_required") + var appRequired: String? = null, + @SerialName("is_experimental") + var isExperimental: Boolean = false, + @SerialName("download_url") + var downloadUrl: String? = null, + @SerialName("is_custom") + var isCustom: Boolean = false, +) + +@Serializable +data class ReleasesIndex( + @SerialName("releases") + var releases: List, +) \ No newline at end of file diff --git a/overportcli/src/main/java/moe/crx/overport/versions/VersionManager.kt b/overportcli/src/main/java/moe/crx/overport/versions/VersionManager.kt new file mode 100644 index 0000000..44edaa0 --- /dev/null +++ b/overportcli/src/main/java/moe/crx/overport/versions/VersionManager.kt @@ -0,0 +1,158 @@ +package moe.crx.overport.versions + +import kotlinx.serialization.json.Json +import moe.crx.overport.utils.HttpUtil +import java.io.File +import java.io.FileOutputStream +import java.util.zip.ZipInputStream + +class VersionManager(val workingDirectory: File) { + + companion object { + val json = Json { ignoreUnknownKeys = true } + const val VERSION = "1.2.0" + + private fun isVersionLower(left: String, right: String): Boolean { + val (lY, lM, lD) = left.substringBeforeLast('-').split('.').map { it.toInt() } + val (rY, rM, rD) = right.substringBeforeLast('-').split('.').map { it.toInt() } + + return lY < rY || lM < rM || lD < rD || left < right + } + + fun isIncompatible(release: OverportRelease): Boolean { + return release.appRequired?.let { isVersionLower(VERSION, it) } ?: false + } + } + + private fun appendInstalled(version: OverportRelease) { + val file = File(workingDirectory, "installed.json") + + val installed = (installed() + version).distinctBy { it.version } + val text = json.encodeToString(ReleasesIndex(installed)) + + file.writeText(text) + } + + fun uninstall(version: String) { + val file = File(workingDirectory, "installed.json") + + val installed = installed() + val selected = versionSelector(version, installed, true) ?: return + val processed = installed.filter { it.version != selected.version } + val text = json.encodeToString(ReleasesIndex(processed)) + + val librariesDir = workingDirectory.resolve("libraries/${selected.version}") + if (librariesDir.isDirectory) { + librariesDir.deleteRecursively() + } + + file.writeText(text) + } + + private fun versionSelector( + version: String, + releases: List, + ignoreCompatibility: Boolean + ): OverportRelease? { + val check: (OverportRelease) -> Boolean = { + !isIncompatible(it) || ignoreCompatibility + } + + return when (version) { + "experimental" -> { + releases.firstOrNull { it.isExperimental && check(it) } + } + + "latest" -> { + releases.firstOrNull { check(it) } + } + + else -> { + releases.firstOrNull { it.version == version && check(it) } + } + } + } + + fun checkout(version: String): String? { + val availableEntry = versionSelector(version, available(), false) + val installedEntry = availableEntry ?: versionSelector(version, installed(), false) + + if (installedEntry != null) { + val librariesDir = workingDirectory.resolve("libraries/${installedEntry.version}") + if (librariesDir.isDirectory) { + return installedEntry.version + } + } + + val entry = availableEntry ?: return null + val downloadUrl = entry.downloadUrl ?: return null + val librariesDir = workingDirectory.resolve("libraries/${entry.version}") + + val downloadedArchive = runCatching { + HttpUtil.download(downloadUrl) + }.getOrElse { + librariesDir.deleteRecursively() + return null + } + + 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))) + } + } + } + + appendInstalled(entry) + + return entry.version + } + + fun getLibraries(version: String): File { + val entry = installed().firstOrNull { it.version == version } + checkNotNull(entry) + check(!isIncompatible(entry)) + + return workingDirectory.resolve("libraries/${entry.version}") + } + + fun installed(): List { + val file = File(workingDirectory, "installed.json") + + runCatching { + val resultBytes = file.readBytes() + val index = json.decodeFromString(String(resultBytes)) + return index.releases.sortedWith(Comparator { o1, o2 -> + if (isVersionLower( + o1.version, + o2.version + ) + ) 1 else -1 + }) + } + + return listOf() + } + + fun available(): List { + val url = "https://ovrp.crx.moe/api/v1/releases/index" + + runCatching { + val resultBytes = HttpUtil.download(url) + val index = json.decodeFromString(String(resultBytes)) + return index.releases.sortedWith(Comparator { o1, o2 -> + if (isVersionLower( + o1.version, + o2.version + ) + ) 1 else -1 + }) + } + + return listOf() + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 3a82598..9d413aa 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,6 +13,10 @@ pluginManagement { mavenCentral() gradlePluginPortal() } + plugins { + kotlin("jvm") version "2.2.0" + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" + } } dependencyResolutionManagement { @@ -28,4 +32,5 @@ dependencyResolutionManagement { } } -include(":composeApp") \ No newline at end of file +include(":composeApp") +include(":overportcli") \ No newline at end of file