mirror of
https://github.com/ovrport/app.git
synced 2026-10-26 19:19:56 -04:00
Release 1.2.0
105 commits
This commit is contained in:
1 parent
4ff09996ba
commit
a939e52939
99 files changed
+3420
-1683
No files matched your search
@@ -35,6 +35,31 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -99,3 +124,34 @@ jobs:
|
||||
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
|
||||
@@ -20,3 +20,5 @@ captures
|
||||
*.apk
|
||||
composeApp/release/output-metadata.json
|
||||
*.salive
|
||||
workspace
|
||||
ignore
|
||||
@@ -9,6 +9,7 @@ plugins {
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
alias(libs.plugins.composeHotReload)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -28,6 +29,7 @@ kotlin {
|
||||
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<org.gradle.jvm.tasks.Jar> {
|
||||
// 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*"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -10,16 +10,15 @@
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:extractNativeLibs="true"
|
||||
android:theme="@style/Theme.Overport"
|
||||
android:theme="@style/Theme.OverportSplash"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Overport">
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="pico.vr.position" android:value="near"/>
|
||||
</activity>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<Pair<String, InputStream>?>() }
|
||||
val saveFlow = remember { MutableSharedFlow<OutputStream?>() }
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package moe.crx.ovrport
|
||||
|
||||
import android.os.Build
|
||||
|
||||
actual fun getPlatform(): Platform = AndroidPlatform("Android ${Build.VERSION.SDK_INT}")
|
||||
@@ -1,6 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.OverportSplash" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
<style name="Theme.Overport" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
<resources>
|
||||
<string name="select_a_file">Select a file</string>
|
||||
<string name="replace_icon_with_icon">Use game icon for replacement</string>
|
||||
<string name="replace_icon_with_cover">Use game cover for replacement</string>
|
||||
<string name="progress_preparing">Preparing libraries…</string>
|
||||
<string name="progress_decompiling">Decompiling APK…</string>
|
||||
<string name="progress_patching">Applying patches…</string>
|
||||
<string name="progress_compiling">Compiling APK…</string>
|
||||
<string name="progress_cleaning">Cleaning up…</string>
|
||||
<string name="patcher_nav_item">Patcher</string>
|
||||
<string name="select_version_title">Select version</string>
|
||||
<string name="you_have_no_versions">You have no versions installed and overport release index is not available.
|
||||
Check your internet connection.
|
||||
</string>
|
||||
<string name="tag_experimental">experimental</string>
|
||||
<string name="tag_latest">latest</string>
|
||||
<string name="tag_custom">custom</string>
|
||||
<string name="status_installed">Installed</string>
|
||||
<string name="status_installed_incompatible">Installed but incompatible</string>
|
||||
<string name="status_not_installed">Not installed</string>
|
||||
<string name="status_not_installed_incompatible">Incompatible with app version</string>
|
||||
<string name="action_confirm">Confirm</string>
|
||||
<string name="action_cancel">Cancel</string>
|
||||
<string name="select_apk_to_begin">Select an APK to begin</string>
|
||||
<string name="or_drag_and_drop_it">(or drag and drop it)</string>
|
||||
<string name="decoding_apk">Processing APK…</string>
|
||||
@@ -13,4 +35,25 @@
|
||||
<string name="status_has_problems">Has problems</string>
|
||||
<string name="status_not_working">Not working</string>
|
||||
<string name="status_loading">Loading…</string>
|
||||
<string name="use_at_your_risk">Not recommended, use at your own risk</string>
|
||||
<string name="patch_copy_libraries">Copy ovrport libraries</string>
|
||||
<string name="patch_copy_ovrplugin_vrapi">Copy OVRPlugin if VrApi is present</string>
|
||||
<string name="patch_fix_min_android_sdk">Fix minimal Android SDK</string>
|
||||
<string name="patch_generate_config">Generate ovrport config</string>
|
||||
<string name="patch_remove_localized_names">Remove localized app names</string>
|
||||
<string name="patch_clean_up_frida">Clean up Frida leftovers</string>
|
||||
<string name="patch_oculus_unity">Patch Oculus detection for Unity</string>
|
||||
<string name="patch_oculus_unreal">Patch Oculus detection for Unreal</string>
|
||||
<string name="patch_vr_metadata">Pico/YVR/Quest metadata</string>
|
||||
<string name="patch_launcher_entry">Fix launcher icon entry</string>
|
||||
<string name="patch_remove_uses_library">Remove uses-library</string>
|
||||
<string name="patch_fix_unreal_crash">Fix Unreal Engine 4 crash</string>
|
||||
<string name="patch_meta_xr_audio">Patch Meta XR Audio</string>
|
||||
<string name="patch_mark_as_debuggable">Mark application as debuggable</string>
|
||||
<string name="patch_mark_allow_backup">Mark application to allow backup</string>
|
||||
<string name="patch_replace_icon_label">Replace application label and icon</string>
|
||||
<string name="patch_remove_unreal_force_quit">Remove Unreal's ForceQuit</string>
|
||||
<string name="patch_force_passthrough">Force enable passthrough</string>
|
||||
<string name="patch_disable_space_warp">Disable application space warp if used</string>
|
||||
<string name="patch_disable_controller_offset">Disable controller tracking offset</string>
|
||||
</resources>
|
||||
@@ -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
|
||||
@@ -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<Pair<String, InputStream>?>,
|
||||
saveFlow: MutableSharedFlow<OutputStream?>,
|
||||
) {
|
||||
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<GithubRelease?>(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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ImageBitmap?>(null) }
|
||||
var onlineLabel by rememberSaveable(applicationPackage) { mutableStateOf<String?>(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
@@ -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<String, List<String>>) -> 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<String>() }.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
@@ -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<BottomNavItem>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
@@ -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<Pair<String, InputStream>?>,
|
||||
saveFlow: MutableSharedFlow<OutputStream?>,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var inputFile by rememberSaveable { mutableStateOf<Pair<String, InputStream>?>(null) }
|
||||
var errorMessage by rememberSaveable { mutableStateOf<Pair<String, String>?>(null) }
|
||||
var patches by remember { mutableStateOf<Map<String, List<String>>>(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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+33
-10
@@ -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,30 +16,34 @@ 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 {
|
||||
if (!showLoading) {
|
||||
drawRoundRect(
|
||||
color = strokeColor,
|
||||
style = Stroke(
|
||||
@@ -54,8 +57,24 @@ fun StartingCard(
|
||||
),
|
||||
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())
|
||||
)
|
||||
}
|
||||
.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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+86
@@ -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<List<OverportRelease>?>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package moe.crx.ovrport.model
|
||||
package moe.crx.overport.app.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -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<PatcherContext?>(null)
|
||||
var versionManager by mutableStateOf(VersionManager(dataDirectory))
|
||||
private set
|
||||
var working by mutableStateOf(false)
|
||||
private set
|
||||
var currentProgress by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var currentProgressFloat by mutableStateOf<Float?>(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<String, List<String>>) {
|
||||
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<List<GithubRelease>>(String(download(RELEASES_URL)))
|
||||
.reduceOrNull { left, right -> if (left.publishedAt > right.publishedAt) left else right }
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package moe.crx.ovrport.ui.theme
|
||||
package moe.crx.overport.app.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
+1
-2
@@ -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,
|
||||
+1
-1
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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<FrdaAsset> = listOf(),
|
||||
val skus: List<String> = 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<FrdaConfig>(jsonString).let { config ->
|
||||
json.encodeToString(
|
||||
OverportConfigV1(
|
||||
database = listOf(
|
||||
OverportConfigApplication(
|
||||
"__current__",
|
||||
config.interaction.parameters.skus,
|
||||
config.interaction.parameters.assets.map { OverportConfigAsset(it.id, it.path, it.type) })
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<GithubIssueLabel>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GithubIssues(
|
||||
@SerialName("items")
|
||||
val items: List<GithubIssue>
|
||||
)
|
||||
@@ -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<GithubIssues>(
|
||||
String(
|
||||
download(
|
||||
Constants.ISSUES_URL.format(
|
||||
URLEncoder.encode(
|
||||
pkg,
|
||||
Charsets.UTF_8
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.items.firstOrNull { it.title.contains(pkg) || it.title.contains(name) }
|
||||
?: json
|
||||
.decodeFromString<GithubIssues>(
|
||||
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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package moe.crx.ovrport.patch
|
||||
|
||||
class CantUpdateOverportException : RuntimeException()
|
||||
|
||||
class CantDecodeApkException : RuntimeException()
|
||||
@@ -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<Patch>()
|
||||
|
||||
init {
|
||||
PATCHES += Patch("Copy ovrport libraries") {
|
||||
selectWorkspace {
|
||||
file.resolve("root/lib")
|
||||
.listFiles()
|
||||
?.forEach {
|
||||
val arch = file.parentFile
|
||||
?.parentFile
|
||||
?.resolve(Constants.LIBRARIES_DIR)
|
||||
?.resolve("lib")
|
||||
?.resolve(it.name)
|
||||
|
||||
if (arch?.exists() == true) {
|
||||
arch.copyRecursively(it, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Fix minimal Android SDK") {
|
||||
selectManifest {
|
||||
readJson().takeNodesEach({ named("manifest") }) {
|
||||
takeNodesEach({ named("uses-sdk") }) {
|
||||
takeAttributesEach({ named("minSdkVersion") }) {
|
||||
take<Int>("data") { 29 }
|
||||
}
|
||||
}
|
||||
}?.let {
|
||||
writeJson(it)
|
||||
}
|
||||
}
|
||||
selectSmali {
|
||||
replace(
|
||||
"invoke-virtual \\{(\\w+), (\\w+)\\}, Landroid/view/Window;->setDecorFitsSystemWindows\\(Z\\)V",
|
||||
""
|
||||
)
|
||||
}
|
||||
selectSmali("androidx/core/view/WindowCompat\$Api30Impl") {
|
||||
replace(
|
||||
"invoke-virtual \\{(\\w+), (\\w+)\\}, Landroid/view/Window;->setDecorFitsSystemWindows\\(Z\\)V",
|
||||
""
|
||||
)
|
||||
}
|
||||
selectSmali("androidx/core/view/WindowInsetsCompat\$TypeImpl30") {
|
||||
replace(
|
||||
"(toPlatformType\\(I\\)I.*?)\\.locals (\\w+)",
|
||||
"$0\nconst v0, 0x0\nreturn v0"
|
||||
)
|
||||
}
|
||||
selectSmali("androidx/core/view/WindowInsetsCompat\$Impl30") {
|
||||
replace(
|
||||
"(getInsets\\(I\\)Landroidx/core/graphics/Insets;.*?)\\.locals (\\w+)",
|
||||
"$0\nsget-object p0, Landroidx/core/graphics/Insets;->NONE:Landroidx/core/graphics/Insets;\nreturn-object p0"
|
||||
)
|
||||
replace("(isVisible\\(I\\)Z.*?)\\.locals (\\w+)", "$0\nconst/4 p0, 0\nreturn p0")
|
||||
}
|
||||
selectSmali("com/epicgames/unreal/GameActivity") {
|
||||
replace(
|
||||
"const-string \\w+?, \\\"vibrator_manager\\\".*?iput-object \\w+?, \\w+?, Lcom/epicgames/unreal/GameActivity;->\\w+?:Landroid/os/Vibrator;",
|
||||
""
|
||||
)
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Generate ovrport config") {
|
||||
selectLibrary("libfrda.config.so") {
|
||||
readJson()?.let { generateConfig(it.toString()) }?.let {
|
||||
createLibrary("liboverport.config.so") {
|
||||
writeJson(JSONObject(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Remove localized app names") {
|
||||
selectResources {
|
||||
readJson().take<JSONArray>("packages") {
|
||||
takeEach<JSONObject> {
|
||||
take<JSONArray>("specs") {
|
||||
takeEach<JSONObject> {
|
||||
take<JSONArray>("types") {
|
||||
takeEach<JSONObject> {
|
||||
val configLanguage = elem<JSONObject>("config").elem<String>("language")
|
||||
|
||||
take<JSONArray>("entries") {
|
||||
takeEach<JSONObject> {
|
||||
this.takeIf {
|
||||
elem<String>("entry_name") != "app_name" || configLanguage == null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}?.let {
|
||||
writeJson(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Clean up Frida") {
|
||||
selectLibrary("libfrda.so") {
|
||||
file.delete()
|
||||
}
|
||||
selectLibrary("libfrda.config.so") {
|
||||
file.delete()
|
||||
}
|
||||
selectLibrary("libscript.so") {
|
||||
file.delete()
|
||||
}
|
||||
|
||||
selectManifest {
|
||||
readJson().takeNodesEach(({ named("manifest") })) {
|
||||
takeNodesEach({ named("application") }) {
|
||||
takeNodesEach({ named("activity") }) {
|
||||
attributeName()?.replace('.', '/')?.let {
|
||||
selectSmali(it) {
|
||||
replace(
|
||||
"const-string (\\w+), \\\"frda\\\"\\s+?invoke-static \\{\\1\\}, Ljava/lang/System;->loadLibrary\\(Ljava/lang/String;\\)V",
|
||||
""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Patch Oculus detection for Unity") {
|
||||
selectSmali("com/unity/oculus/OculusUnity") {
|
||||
replace(
|
||||
"(getIsOnOculusHardware\\(\\)Z.*?)return (\\w+)",
|
||||
"$1\nconst/4 $2, 0x1\nreturn $2"
|
||||
)
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Patch Oculus detection for Unreal") {
|
||||
selectSmali(
|
||||
"com/epicgames/ue4/GameActivity",
|
||||
"com/epicgames/unreal/GameActivity",
|
||||
) {
|
||||
replace(
|
||||
"sget-object (\\w+), Landroid/os/Build;->MANUFACTURER:Ljava/lang/String;",
|
||||
"const-string $1, \"Oculus\""
|
||||
)
|
||||
replace(
|
||||
"sget-object (\\w+), Landroid/os/Build;->MODEL:Ljava/lang/String;",
|
||||
"const-string $1, \"Quest 2\""
|
||||
)
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Pico/YVR/Quest metadata") {
|
||||
selectManifest {
|
||||
readJson().takeNodesEach(({ named("manifest") })) {
|
||||
takeNodesEach({ named("application") }) {
|
||||
takeNodesEach({ named("meta-data") }) {
|
||||
if (attributeName() == "com.oculus.supportedDevices") null else this
|
||||
}
|
||||
takeNodes {
|
||||
this
|
||||
?.put(createMetadata("pvr.app.type", "vr"))
|
||||
?.put(createMetadata("com.yvr.intent.category.VR", "vr_only"))
|
||||
?.put(createMetadata("com.oculus.supportedDevices", "all"))
|
||||
}
|
||||
}
|
||||
}?.let {
|
||||
writeJson(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
PATCHES += Patch("Fix launcher icon entry") {
|
||||
selectManifest {
|
||||
readJson().takeNodesEach(({ named("manifest") })) {
|
||||
takeNodesEach({ named("application") }) {
|
||||
takeNodesEach({ named("activity") }) {
|
||||
takeNodesEach({ named("intent-filter") }) {
|
||||
val hasIntent = elem<JSONArray>("nodes").elemEach<JSONObject> { 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Patch>) {
|
||||
patches.forEach {
|
||||
it.executor(PatchExecutor(workingDir))
|
||||
}
|
||||
|
||||
val encoder = ApkModuleJsonEncoder().apply {
|
||||
dexEncoder = DexEncoder { _, dir ->
|
||||
dir
|
||||
.resolve("smali")
|
||||
.listFiles()
|
||||
?.filter { file -> file.isDirectory }
|
||||
?.map {
|
||||
dir.resolve(".cache").mkdirs()
|
||||
val file = dir.resolve(".cache").resolve("${it.name}.dex")
|
||||
|
||||
val smaliOptions = SmaliOptions().apply {
|
||||
outputDexFile = file.absolutePath
|
||||
jobs = 4
|
||||
apiLevel = 29
|
||||
}
|
||||
|
||||
Smali.assemble(smaliOptions, it.absolutePath)
|
||||
FileInputSource(file, file.name)
|
||||
}
|
||||
?.toList()
|
||||
}
|
||||
}
|
||||
|
||||
encoder.scanDirectory(workingDir)
|
||||
encoder.apkModule.run {
|
||||
zipEntryMap.autoSortApkFiles()
|
||||
writeApk(outputApk)
|
||||
close()
|
||||
}
|
||||
|
||||
val signatureDir = patcherDirectory.resolve("signatures")
|
||||
signatureDir.mkdirs()
|
||||
val signature = (appPackage() ?: "default")
|
||||
.plus(".keystore")
|
||||
.let { signatureDir.resolve(it) }
|
||||
.let { SignatureStore.getSignature(it) }
|
||||
|
||||
val tempFile = outputApk
|
||||
.parentFile
|
||||
?.resolve(outputApk.name + ".temp")
|
||||
?.let {
|
||||
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
|
||||
}
|
||||
@@ -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<Patch>) -> Unit
|
||||
) {
|
||||
OverportTheme(darkTheme = true) {
|
||||
var versionToUpdate by remember { mutableStateOf<GithubRelease?>(null) }
|
||||
val urlHandler = LocalUriHandler.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
versionToUpdate = viewModel.versionToUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(
|
||||
if (getPlatform() is AndroidPlatform) {
|
||||
Modifier.clip(RoundedCornerShape(32.dp))
|
||||
} else Modifier
|
||||
),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
painterResource(Res.drawable.overport),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(112.dp, 24.dp)
|
||||
)
|
||||
Text(
|
||||
Constants.OVRPORT_VERSION,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
}
|
||||
},
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Patch>) -> 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PatcherContext?>(null)
|
||||
var working by mutableStateOf(false)
|
||||
private set
|
||||
private var fileName: String = "game"
|
||||
private var imageConverter: ((File) -> ImageBitmap)? = null
|
||||
|
||||
private suspend fun usePatcher(block: PatcherContext.() -> Unit) {
|
||||
working = true
|
||||
withContext(Dispatchers.IO) {
|
||||
patcherState?.block()
|
||||
}
|
||||
working = false
|
||||
}
|
||||
|
||||
suspend fun import(dataDirectory: File, name: String, inputStream: InputStream) {
|
||||
fileName = name
|
||||
patcherState = PatcherContext(dataDirectory)
|
||||
usePatcher { prepare(inputStream) }
|
||||
}
|
||||
|
||||
fun patchedName(): String {
|
||||
return fileName.substringBeforeLast('.').let { "$it.output.apk" }
|
||||
}
|
||||
|
||||
suspend fun process(patches: List<Patch>) {
|
||||
usePatcher { patch(patches) }
|
||||
}
|
||||
|
||||
suspend fun export(outputStream: OutputStream) {
|
||||
usePatcher { export(outputStream) }
|
||||
}
|
||||
|
||||
suspend fun cancel() {
|
||||
usePatcher { cleanup() }
|
||||
fileName = "game"
|
||||
patcherState = null
|
||||
}
|
||||
|
||||
fun currentAppName(): String? {
|
||||
return patcherState?.appName()
|
||||
}
|
||||
|
||||
fun currentAppPackage(): String? {
|
||||
return patcherState?.appPackage()
|
||||
}
|
||||
|
||||
fun currentAppVersion(): String? {
|
||||
return patcherState?.appVersion()
|
||||
}
|
||||
|
||||
fun setAppIconConverter(converter: (File) -> ImageBitmap) {
|
||||
imageConverter = converter
|
||||
}
|
||||
|
||||
fun currentAppIcon(): ImageBitmap? {
|
||||
return patcherState?.appIcon()?.let { file ->
|
||||
imageConverter?.let {
|
||||
it(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isApkLoaded(): Boolean {
|
||||
return patcherState?.workingApk != null
|
||||
}
|
||||
|
||||
fun versionToUpdate(): GithubRelease? {
|
||||
return runCatching {
|
||||
json
|
||||
.decodeFromString<List<GithubRelease>>(String(download(Constants.RELEASES_URL)))
|
||||
.reduceOrNull { left, right -> if (left.publishedAt > right.publishedAt) left else right }
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -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<String>("name") == name
|
||||
}
|
||||
|
||||
fun JSONObject?.takeNodes(block: JSONArray?.() -> JSONArray? = { this }): JSONObject? {
|
||||
return take("nodes", block)
|
||||
}
|
||||
|
||||
fun JSONObject?.takeAttributes(block: JSONArray?.() -> JSONArray? = { this }): JSONObject? {
|
||||
return take("attributes", block)
|
||||
}
|
||||
|
||||
fun JSONObject?.takeNodesEach(
|
||||
condition: JSONObject?.() -> Boolean = { true },
|
||||
block: JSONObject?.() -> JSONObject? = { this }
|
||||
): JSONObject? {
|
||||
return takeNodes {
|
||||
takeEach(condition, block)
|
||||
}
|
||||
}
|
||||
|
||||
fun JSONObject?.takeAttributesEach(
|
||||
condition: JSONObject?.() -> Boolean = { true },
|
||||
block: JSONObject?.() -> JSONObject? = { this }
|
||||
): JSONObject? {
|
||||
return takeAttributes {
|
||||
takeEach(condition, block)
|
||||
}
|
||||
}
|
||||
|
||||
fun JSONObject?.attributeName(): String? {
|
||||
return elem<JSONArray>("attributes")
|
||||
.elemEach<JSONObject> { named("name") }
|
||||
.firstOrNull()
|
||||
.elem<String>("data")
|
||||
}
|
||||
|
||||
fun createMetadata(name: String, value: String): JSONObject {
|
||||
return JSONObject()
|
||||
.put("node_type", "element")
|
||||
.put("name", "meta-data")
|
||||
.put(
|
||||
"attributes",
|
||||
JSONArray()
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("name", "name")
|
||||
.put("id", 16842755)
|
||||
.put("uri", "http://schemas.android.com/apk/res/android")
|
||||
.put("prefix", "android")
|
||||
.put("value_type", "STRING")
|
||||
.put("data", name)
|
||||
)
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("name", "value")
|
||||
.put("id", 16842788)
|
||||
.put("uri", "http://schemas.android.com/apk/res/android")
|
||||
.put("prefix", "android")
|
||||
.put("value_type", "STRING")
|
||||
.put("data", value)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createCategory(value: String): JSONObject {
|
||||
return JSONObject()
|
||||
.put("node_type", "element")
|
||||
.put("name", "category")
|
||||
.put(
|
||||
"attributes",
|
||||
JSONArray()
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("name", "name")
|
||||
.put("id", 16842755)
|
||||
.put("uri", "http://schemas.android.com/apk/res/android")
|
||||
.put("prefix", "android")
|
||||
.put("value_type", "STRING")
|
||||
.put("data", value)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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<String>) = 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<Pair<String, InputStream>?>() }
|
||||
val saveFlow = remember { MutableSharedFlow<OutputStream?>() }
|
||||
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<File>? = event.awtTransferable
|
||||
.takeIf { it.isDataFlavorSupported(DataFlavor.javaFileListFlavor) }
|
||||
?.getTransferData(DataFlavor.javaFileListFlavor)
|
||||
?.let { it as? List<*> }
|
||||
?.filterIsInstance<File>()
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package moe.crx.ovrport
|
||||
|
||||
actual fun getPlatform(): Platform = JVMPlatform("Java ${System.getProperty("java.version")}")
|
||||
@@ -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<File>? = event.awtTransferable
|
||||
.takeIf { it.isDataFlavorSupported(DataFlavor.javaFileListFlavor) }
|
||||
?.getTransferData(DataFlavor.javaFileListFlavor)
|
||||
?.let { it as? List<*> }
|
||||
?.filterIsInstance<File>()
|
||||
|
||||
if (value?.size == 1) {
|
||||
coroutineScope.launch {
|
||||
openFile(value.first())
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().dragAndDropTarget(
|
||||
shouldStartDragAndDrop = { !viewModel.working && !viewModel.isApkLoaded() },
|
||||
target = dragAndDropTarget
|
||||
)) {
|
||||
AppContent(
|
||||
viewModel = viewModel.apply {
|
||||
setAppIconConverter {
|
||||
FileInputStream(it).use { stream ->
|
||||
Image.makeFromEncoded(stream.readAllBytes()).toComposeImageBitmap()
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHostState = snackbarHostState,
|
||||
onOpen = {
|
||||
val fileDialog = FileDialog(Frame(), "Select a file", FileDialog.LOAD)
|
||||
fileDialog.isVisible = true
|
||||
val file = fileDialog.file?.let { File(fileDialog.directory).resolve(it) }
|
||||
|
||||
coroutineScope.launch {
|
||||
if (file != null) {
|
||||
openFile(file)
|
||||
}
|
||||
}
|
||||
},
|
||||
onConfirm = {
|
||||
val fileDialog = FileDialog(Frame(), "Select a file", FileDialog.SAVE)
|
||||
|
||||
coroutineScope.run {
|
||||
fileDialog.file = viewModel.patchedName()
|
||||
}
|
||||
|
||||
fileDialog.isVisible = true
|
||||
val file = fileDialog.file?.let { File(fileDialog.directory).resolve(it) }
|
||||
|
||||
coroutineScope.launch {
|
||||
if (file != null) {
|
||||
viewModel.process(it)
|
||||
FileOutputStream(file).use { s ->
|
||||
viewModel.export(s)
|
||||
launch {
|
||||
snackbarHostState.showSnackbar(apkFileExportedMessage)
|
||||
}
|
||||
}
|
||||
viewModel.cancel()
|
||||
}
|
||||
}
|
||||
},
|
||||
onCancel = {
|
||||
coroutineScope.launch {
|
||||
viewModel.cancel()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -10,4 +10,4 @@ android.nonTransitiveRClass=true
|
||||
android.useAndroidX=true
|
||||
|
||||
#Application
|
||||
appVersion=1.1.0
|
||||
appVersion=1.2.0
|
||||
+18
-15
@@ -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" }
|
||||
@@ -32,3 +34,4 @@ composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-mu
|
||||
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" }
|
||||
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hot-reload" }
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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>("shadowJar") {
|
||||
mergeServiceFiles()
|
||||
}
|
||||
Binary file not shown.
File renamed without changes.
@@ -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<String>): Boolean
|
||||
|
||||
override fun toString() = name()
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
val command = args.firstOrNull()?.let { COMMANDS[it] } ?: run {
|
||||
DisplayHelpCommand.printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
val successful = runBlocking {
|
||||
command.execute(args.drop(1))
|
||||
}
|
||||
if (!successful) {
|
||||
command.printHelp()
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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 <command> [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 <command> 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<String>): Boolean {
|
||||
val command = args.firstOrNull()?.let { COMMANDS[it] } ?: run {
|
||||
DisplayHelpCommand.printHelp()
|
||||
return true
|
||||
}
|
||||
|
||||
val hasHelp = command.printHelp()
|
||||
if (!hasHelp) {
|
||||
printHelp()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -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<String>): Boolean {
|
||||
println("Available patches:")
|
||||
|
||||
PatchStore.all().forEach {
|
||||
print("- ")
|
||||
println(it.name)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -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=<path> [--output=<path>]")
|
||||
println(" [--output-name=<format>] [--workspace=<path>] [--patches=<names>] [--version=<value>]")
|
||||
println()
|
||||
println("--input=<path>")
|
||||
println("Specifies input APK file to patch.")
|
||||
println()
|
||||
println("--output=<path>")
|
||||
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=<format>")
|
||||
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=<path>")
|
||||
println("Specifies workspace directory path.")
|
||||
println("If not specified, uses default one in user's home directory.")
|
||||
println()
|
||||
println("--patches=<value>")
|
||||
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 <patch name>=<comma-separated arguments>.")
|
||||
println()
|
||||
println("--version=<value>")
|
||||
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<String, List<String>>? = null,
|
||||
var nameFormat: String? = null,
|
||||
var overportVersion: String? = null,
|
||||
)
|
||||
|
||||
private fun scanArguments(args: List<String>): 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<String>): 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
|
||||
}
|
||||
}
|
||||
@@ -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=<path>] [--version=<value>]")
|
||||
println()
|
||||
println("--workspace=<path>")
|
||||
println("Specifies workspace directory path.")
|
||||
println("If not specified, uses default one in user's home directory.")
|
||||
println()
|
||||
println("--version=<value>")
|
||||
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<String>): 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<OverportRelease>, installed: List<OverportRelease>) {
|
||||
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<String>): 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
|
||||
}
|
||||
}
|
||||
@@ -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<ApplicationMetaImage>,
|
||||
)
|
||||
|
||||
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<ApplicationMetaInfo>(string)
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -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<FridaConfig>(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)
|
||||
}
|
||||
@@ -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<FridaAsset> = listOf(),
|
||||
val skus: List<String> = listOf()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FridaInteraction(
|
||||
val parameters: FridaParameters = FridaParameters(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FridaConfig(
|
||||
val interaction: FridaInteraction = FridaInteraction(),
|
||||
)
|
||||
+25
-9
@@ -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<OverportConfigAsset>,
|
||||
)
|
||||
|
||||
@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<String>,
|
||||
)
|
||||
|
||||
@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<OverportConfigApplication>,
|
||||
var database: List<OverportConfigApplication> = listOf(),
|
||||
@SerialName("patched")
|
||||
var patched: OverportPatchedInfo? = null,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Int>("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", "")
|
||||
}
|
||||
}
|
||||
@@ -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;"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<JSONArray>("nodes").elemEach<JSONObject> { 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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<JSONObject> { named("allowBackup") }.isEmpty()) {
|
||||
this?.put(createAllowBackup())
|
||||
}
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<JSONObject> { named("debuggable") }.isEmpty()) {
|
||||
this?.put(createDebuggable())
|
||||
}
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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\""
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<JSONArray>("packages") {
|
||||
takeEach<JSONObject> {
|
||||
take<JSONArray>("specs") {
|
||||
takeEach<JSONObject> {
|
||||
take<JSONArray>("types") {
|
||||
takeEach<JSONObject> {
|
||||
val configLanguage = elem<JSONObject>("config").elem<String>("language")
|
||||
|
||||
take<JSONArray>("entries") {
|
||||
takeEach<JSONObject> {
|
||||
this.takeIf {
|
||||
elem<String>("entry_name") != "app_name" || configLanguage == null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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..<bytes.size - inputBytes.size + 1) {
|
||||
var matches = true
|
||||
|
||||
for (j in i..<bytes.size) {
|
||||
val checkIndex = j - i
|
||||
if (checkIndex >= inputBytes.size) {
|
||||
break
|
||||
}
|
||||
|
||||
if (inputBytes[checkIndex] != "??" && bytes[j].toUByte() != inputBytes[checkIndex].toUByte(16)) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
val replaced = bytes.slice(0..<i) +
|
||||
output.split(' ').map { it.toUByte(16).toByte() } +
|
||||
bytes.slice(i + inputBytes.size..<bytes.size)
|
||||
file.writeBytes(replaced.toByteArray())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package moe.crx.overport.patching
|
||||
|
||||
data class Patch(
|
||||
val name: String,
|
||||
val isRecommended: Boolean = true,
|
||||
val executor: PatchExecutor.(List<String>) -> Unit,
|
||||
)
|
||||
+59
-53
@@ -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..<bytes.size-inputBytes.size+1) {
|
||||
var matches = true
|
||||
|
||||
for (j in i..<bytes.size) {
|
||||
val checkIndex = j - i
|
||||
if (checkIndex >= inputBytes.size) {
|
||||
break
|
||||
}
|
||||
|
||||
if (inputBytes[checkIndex] != "??" && bytes[j].toUByte() != inputBytes[checkIndex].toUByte(16)) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
val replaced = bytes.slice(0..<i) +
|
||||
output.split(' ').map { it.toUByte(16).toByte() } +
|
||||
bytes.slice(i+inputBytes.size..<bytes.size)
|
||||
file.writeBytes(replaced.toByteArray())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PatchExecutor(private val workingDir: File) {
|
||||
fun selectWorkspace(block: FileTransformer.() -> Unit) {
|
||||
workingDir.run {
|
||||
if (exists()) {
|
||||
@@ -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<OverportConfig>(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")
|
||||
@@ -0,0 +1,50 @@
|
||||
package moe.crx.overport.patching
|
||||
|
||||
import moe.crx.overport.patches.*
|
||||
|
||||
object PatchStore {
|
||||
private val PATCHES = mutableMapOf<String, Patch>()
|
||||
|
||||
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<Patch> {
|
||||
return PATCHES.values.toList()
|
||||
}
|
||||
|
||||
fun get(name: String): Patch? {
|
||||
return PATCHES[name]
|
||||
}
|
||||
|
||||
fun recommended(): List<Patch> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<String>>,
|
||||
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
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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()
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+10
-22
@@ -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,15 +22,12 @@ inline fun <reified T> JSONArray?.take(index: Int, block: T?.() -> T? = { this }
|
||||
}
|
||||
|
||||
inline fun <reified T> 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 ->
|
||||
this?.keys()?.asSequence()?.toList()?.forEach { i ->
|
||||
elem<T>(i).let {
|
||||
if (condition(it)) {
|
||||
block(it)?.let { value -> put(i, value) }
|
||||
?: remove(i)
|
||||
block(it)?.let { value -> put(i, value) } ?: remove(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,15 +36,12 @@ inline fun <reified T> JSONObject?.takeEach(
|
||||
}
|
||||
|
||||
inline fun <reified T> 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 ->
|
||||
this?.length()?.let { (it - 1).downTo(0) }?.forEach { i ->
|
||||
elem<T>(i).let {
|
||||
if (condition(it)) {
|
||||
block(it)?.let { value -> put(i, value) }
|
||||
?: remove(i)
|
||||
block(it)?.let { value -> put(i, value) } ?: remove(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,17 +50,11 @@ inline fun <reified T> JSONArray?.takeEach(
|
||||
}
|
||||
|
||||
inline fun <reified T> JSONObject?.elemEach(condition: T?.() -> Boolean = { true }): List<T> {
|
||||
return this?.keys()?.asSequence()?.toList()
|
||||
?.mapNotNull { i -> elem<T>(i) }
|
||||
?.filter { condition(it) }
|
||||
?.toList()
|
||||
return this?.keys()?.asSequence()?.toList()?.mapNotNull { i -> elem<T>(i) }?.filter { condition(it) }?.toList()
|
||||
?: listOf()
|
||||
}
|
||||
|
||||
inline fun <reified T> JSONArray?.elemEach(condition: T?.() -> Boolean = { true }): List<T> {
|
||||
return this?.length()?.let { (it - 1).downTo(0) }
|
||||
?.mapNotNull { i -> elem<T>(i) }
|
||||
?.filter { condition(it) }
|
||||
?.toList()
|
||||
?: listOf()
|
||||
return this?.length()?.let { (it - 1).downTo(0) }?.mapNotNull { i -> elem<T>(i) }?.filter { condition(it) }
|
||||
?.toList() ?: listOf()
|
||||
}
|
||||
@@ -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<String>("name") == name
|
||||
}
|
||||
|
||||
fun JSONObject?.takeNodes(block: JSONArray?.() -> JSONArray? = { this }): JSONObject? {
|
||||
return take("nodes", block)
|
||||
}
|
||||
|
||||
fun JSONObject?.takeAttributes(block: JSONArray?.() -> JSONArray? = { this }): JSONObject? {
|
||||
return take("attributes", block)
|
||||
}
|
||||
|
||||
fun JSONObject?.takeNodesEach(
|
||||
condition: JSONObject?.() -> Boolean = { true }, block: JSONObject?.() -> JSONObject? = { this }
|
||||
): JSONObject? {
|
||||
return takeNodes {
|
||||
takeEach(condition, block)
|
||||
}
|
||||
}
|
||||
|
||||
fun JSONObject?.takeAttributesEach(
|
||||
condition: JSONObject?.() -> Boolean = { true }, block: JSONObject?.() -> JSONObject? = { this }
|
||||
): JSONObject? {
|
||||
return takeAttributes {
|
||||
takeEach(condition, block)
|
||||
}
|
||||
}
|
||||
|
||||
fun JSONObject?.nameAttribute(): String? {
|
||||
return elem<JSONArray>("attributes").elemEach<JSONObject> { named("name") }.firstOrNull().elem<String>("data")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Any>): String {
|
||||
val max = list.map { it.toString().length }.maxOfOrNull { it } ?: 0
|
||||
return " ".repeat(max(max - key.toString().length, 0) + 4)
|
||||
}
|
||||
}
|
||||
@@ -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<OverportRelease>,
|
||||
)
|
||||
@@ -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<OverportRelease>,
|
||||
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<OverportRelease> {
|
||||
val file = File(workingDirectory, "installed.json")
|
||||
|
||||
runCatching {
|
||||
val resultBytes = file.readBytes()
|
||||
val index = json.decodeFromString<ReleasesIndex>(String(resultBytes))
|
||||
return index.releases.sortedWith(Comparator { o1, o2 ->
|
||||
if (isVersionLower(
|
||||
o1.version,
|
||||
o2.version
|
||||
)
|
||||
) 1 else -1
|
||||
})
|
||||
}
|
||||
|
||||
return listOf()
|
||||
}
|
||||
|
||||
fun available(): List<OverportRelease> {
|
||||
val url = "https://ovrp.crx.moe/api/v1/releases/index"
|
||||
|
||||
runCatching {
|
||||
val resultBytes = HttpUtil.download(url)
|
||||
val index = json.decodeFromString<ReleasesIndex>(String(resultBytes))
|
||||
return index.releases.sortedWith(Comparator { o1, o2 ->
|
||||
if (isVersionLower(
|
||||
o1.version,
|
||||
o2.version
|
||||
)
|
||||
) 1 else -1
|
||||
})
|
||||
}
|
||||
|
||||
return listOf()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -29,3 +33,4 @@ dependencyResolutionManagement {
|
||||
}
|
||||
|
||||
include(":composeApp")
|
||||
include(":overportcli")
|
||||
Reference in new issue
Block a user