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
+3525
-1788
No files matched your search
+10
-28
@@ -9,6 +9,7 @@ plugins {
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
alias(libs.plugins.composeHotReload)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -17,17 +18,18 @@ kotlin {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
jvm("desktop")
|
||||
|
||||
|
||||
sourceSets {
|
||||
val desktopMain by getting
|
||||
|
||||
|
||||
androidMain.dependencies {
|
||||
implementation(compose.preview)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
}
|
||||
commonMain.dependencies {
|
||||
implementation(project(":overportcli"))
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material3)
|
||||
@@ -38,10 +40,7 @@ kotlin {
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation(libs.android.tools.build)
|
||||
implementation(libs.bouncycastle.pkix)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(fileTree("libs"))
|
||||
}
|
||||
desktopMain.dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
@@ -51,11 +50,11 @@ kotlin {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "moe.crx.ovrport"
|
||||
namespace = "moe.crx.overport.app"
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "moe.crx.ovrport"
|
||||
applicationId = "moe.crx.overport.app"
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = appVersion.split('.').run { get(0).toInt() * 1000000 + get(1).toInt() * 1000 + get(2).toInt() }
|
||||
@@ -89,18 +88,19 @@ dependencies {
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "moe.crx.ovrport.MainKt"
|
||||
mainClass = "moe.crx.overport.app.ComposeMainKt"
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguard {
|
||||
configurationFiles.from("proguard-rules.pro")
|
||||
isEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Exe, TargetFormat.AppImage)
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Exe, TargetFormat.Deb, TargetFormat.Rpm)
|
||||
packageName = "overport"
|
||||
packageVersion = appVersion
|
||||
|
||||
@@ -120,21 +120,3 @@ compose.desktop {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<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*"
|
||||
)
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,25 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
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"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:extractNativeLibs="true"
|
||||
android:theme="@style/Theme.Overport"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Overport">
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:extractNativeLibs="true"
|
||||
android:theme="@style/Theme.OverportSplash"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+45
-22
@@ -1,15 +1,14 @@
|
||||
package moe.crx.ovrport.ui
|
||||
package moe.crx.overport.app.composables
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Park
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
@@ -17,45 +16,65 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
import moe.crx.ovrport.AndroidPlatform
|
||||
import moe.crx.ovrport.getPlatform
|
||||
import moe.crx.overport.app.AndroidPlatform
|
||||
import moe.crx.overport.app.getPlatform
|
||||
import moe.crx.overport.app.util.ModifierUtil.rounded
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||
import overportapp.composeapp.generated.resources.Res
|
||||
import overportapp.composeapp.generated.resources.decoding_apk
|
||||
import overportapp.composeapp.generated.resources.or_drag_and_drop_it
|
||||
import overportapp.composeapp.generated.resources.select_apk_to_begin
|
||||
import java.util.*
|
||||
|
||||
val isWinter = Calendar.getInstance().get(Calendar.MONTH).let { it == 0 || it == 1 || it == 11 }
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun StartingCard(
|
||||
innerPadding: PaddingValues = PaddingValues(16.dp),
|
||||
showLoading: Boolean = false,
|
||||
currentString: String? = null,
|
||||
onCardClick: () -> Unit = {}
|
||||
) {
|
||||
val strokeColor = LocalContentColor.current
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
.drawBehind {
|
||||
drawRoundRect(
|
||||
color = strokeColor,
|
||||
style = Stroke(
|
||||
width = 4.dp.toPx(),
|
||||
pathEffect = PathEffect.dashPathEffect(
|
||||
floatArrayOf(
|
||||
16.dp.toPx(),
|
||||
16.dp.toPx()
|
||||
), 16.dp.toPx()
|
||||
if (!showLoading) {
|
||||
drawRoundRect(
|
||||
color = strokeColor,
|
||||
style = Stroke(
|
||||
width = 4.dp.toPx(),
|
||||
pathEffect = PathEffect.dashPathEffect(
|
||||
floatArrayOf(
|
||||
16.dp.toPx(),
|
||||
16.dp.toPx()
|
||||
), 16.dp.toPx()
|
||||
)
|
||||
),
|
||||
cornerRadius = CornerRadius(32.dp.toPx())
|
||||
)
|
||||
if (isWinter) {
|
||||
drawRoundRect(
|
||||
color = Color(0xFFEF5350),
|
||||
style = Stroke(
|
||||
width = 4.dp.toPx(),
|
||||
pathEffect = PathEffect.dashPathEffect(
|
||||
floatArrayOf(
|
||||
16.dp.toPx(),
|
||||
16.dp.toPx()
|
||||
), 0f
|
||||
)
|
||||
),
|
||||
cornerRadius = CornerRadius(32.dp.toPx())
|
||||
)
|
||||
),
|
||||
cornerRadius = CornerRadius(32.dp.toPx())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.clip(RoundedCornerShape(32.dp))
|
||||
.rounded(32.dp)
|
||||
.clickable(!showLoading, onClick = onCardClick),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color.Transparent
|
||||
@@ -70,7 +89,11 @@ fun StartingCard(
|
||||
) {
|
||||
if (!showLoading) {
|
||||
Icon(
|
||||
Icons.Default.Download,
|
||||
if (isWinter) {
|
||||
Icons.Default.Park
|
||||
} else {
|
||||
Icons.Default.Download
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
@@ -96,7 +119,7 @@ fun StartingCard(
|
||||
.height(96.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.decoding_apk)
|
||||
text = currentString ?: stringResource(Res.string.decoding_apk)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,66 +0,0 @@
|
||||
package moe.crx.ovrport.model
|
||||
|
||||
import kotlinx.serialization.EncodeDefault
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class OverportConfigLanguage(
|
||||
@SerialName("english_name")
|
||||
var englishName: String = "",
|
||||
@SerialName("native_name")
|
||||
var nativeName: String = "",
|
||||
@SerialName("tag")
|
||||
var tag: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OverportConfigAsset(
|
||||
@SerialName("asset_id")
|
||||
var assetId: Long = 0,
|
||||
@SerialName("file_path")
|
||||
var filePath: String,
|
||||
@SerialName("asset_type")
|
||||
var assetType: String = "store",
|
||||
@SerialName("language")
|
||||
var language: OverportConfigLanguage = OverportConfigLanguage(),
|
||||
@SerialName("download_status")
|
||||
var downloadStatus: String = "installed",
|
||||
@SerialName("iap_status")
|
||||
var iapStatus: String = "entitled",
|
||||
@SerialName("metadata")
|
||||
var metadata: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OverportConfigApplication(
|
||||
@SerialName("app_id")
|
||||
var appId: String,
|
||||
@SerialName("products")
|
||||
var products: List<String>,
|
||||
@SerialName("assets")
|
||||
var assets: List<OverportConfigAsset>,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class OverportConfigV1(
|
||||
@SerialName("version")
|
||||
@EncodeDefault
|
||||
var version: Int = 1,
|
||||
@SerialName("suggested_texture_width")
|
||||
var suggestedTextureWidth: Int = 0,
|
||||
@SerialName("suggested_texture_height")
|
||||
var suggestedTextureHeight: Int = 0,
|
||||
@SerialName("suggested_cpu_level")
|
||||
var suggestedCpuLevel: Int = 0,
|
||||
@SerialName("suggested_gpu_level")
|
||||
var suggestedGpuLevel: Int = 0,
|
||||
@SerialName("vibration_multiplier")
|
||||
var vibrationMultiplier: Double = 1.0,
|
||||
@SerialName("vibration_frequency_multiplier")
|
||||
var vibrationFrequencyMultiplier: Double = 1.0,
|
||||
@SerialName("database")
|
||||
var database: List<OverportConfigApplication>,
|
||||
)
|
||||
@@ -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,176 +0,0 @@
|
||||
package moe.crx.ovrport.patch
|
||||
|
||||
import com.reandroid.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
data class Patch(
|
||||
val name: String = "",
|
||||
val desc: String? = null,
|
||||
val executor: PatchExecutor.() -> Unit
|
||||
)
|
||||
|
||||
class FileTransformer(val file: File) {
|
||||
fun replace(input: String, output: String) {
|
||||
file.writeText(Regex(input, RegexOption.DOT_MATCHES_ALL).replace(file.readText(), output))
|
||||
}
|
||||
|
||||
fun readText(): String {
|
||||
return file.readText()
|
||||
}
|
||||
|
||||
fun append(line: String) {
|
||||
file.appendText(line)
|
||||
}
|
||||
|
||||
fun readJson(): JSONObject? {
|
||||
return runCatching { JSONObject(file.readText()) }.getOrNull()
|
||||
}
|
||||
|
||||
fun writeJson(json: JSONObject) {
|
||||
file.writeText(json.toString())
|
||||
}
|
||||
|
||||
fun replaceHex(input: String, output: String) {
|
||||
val bytes = file.readBytes()
|
||||
val inputBytes = input.split(' ')
|
||||
|
||||
for (i in 0..<bytes.size-inputBytes.size+1) {
|
||||
var matches = true
|
||||
|
||||
for (j in i..<bytes.size) {
|
||||
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()) {
|
||||
FileTransformer(this).block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectFile(fileName: String, block: FileTransformer.() -> Unit) {
|
||||
workingDir.resolve(fileName).run {
|
||||
if (exists()) {
|
||||
FileTransformer(this).block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectResources(block: FileTransformer.() -> Unit) {
|
||||
selectFile("resources/resources.arsc.json", block)
|
||||
}
|
||||
|
||||
fun selectManifest(block: FileTransformer.() -> Unit) {
|
||||
selectFile("AndroidManifest.xml.json", block)
|
||||
}
|
||||
|
||||
fun selectLibrary(fileName: String, block: FileTransformer.() -> Unit) {
|
||||
listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64").forEach { arch ->
|
||||
val archDirectory = workingDir.resolve("root/lib/$arch")
|
||||
if (archDirectory.exists()) {
|
||||
archDirectory.resolve(fileName).run {
|
||||
if (exists()) {
|
||||
FileTransformer(this).block()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createLibrary(fileName: String, block: FileTransformer.() -> Unit) {
|
||||
listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64").forEach { arch ->
|
||||
val archDirectory = workingDir.resolve("root/lib/$arch")
|
||||
if (archDirectory.exists()) {
|
||||
archDirectory.resolve(fileName).run {
|
||||
createNewFile()
|
||||
FileTransformer(this).block()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSmali(vararg fileNames: String, block: FileTransformer.() -> Unit) {
|
||||
workingDir.resolve("smali").listFiles()?.forEach { dir ->
|
||||
if (fileNames.isEmpty()) {
|
||||
dir.walk().forEach { file ->
|
||||
if (file.isFile && file.name.endsWith(".smali")) {
|
||||
FileTransformer(file).block()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fileNames.forEach { fileName ->
|
||||
dir.resolve("${fileName}.1.smali").run {
|
||||
if (exists()) {
|
||||
FileTransformer(this).block()
|
||||
}
|
||||
}
|
||||
dir.resolve("${fileName}.smali").run {
|
||||
if (exists()) {
|
||||
FileTransformer(this).block()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createSmali(fileName: String, block: FileTransformer.() -> Unit) {
|
||||
workingDir.resolve("smali").listFiles()?.forEach { dir ->
|
||||
dir.resolve("${fileName}.1.smali").run {
|
||||
if (exists()) {
|
||||
FileTransformer(this).block()
|
||||
return
|
||||
}
|
||||
}
|
||||
dir.resolve("${fileName}.smali").run {
|
||||
if (exists()) {
|
||||
FileTransformer(this).block()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
workingDir
|
||||
.resolve("smali")
|
||||
.listFiles()
|
||||
?.reduceOrNull { left, right ->
|
||||
val leftValue = left.name.substringAfter("classes").toIntOrNull() ?: 0
|
||||
val rightValue = right.name.substringAfter("classes").toIntOrNull() ?: 0
|
||||
if (rightValue > leftValue) right else left
|
||||
}
|
||||
.let {
|
||||
it ?: workingDir.resolve("smali/classes").apply { mkdirs() }
|
||||
}
|
||||
.resolve("${fileName}.smali")
|
||||
.apply {
|
||||
parentFile?.mkdirs()
|
||||
createNewFile()
|
||||
}
|
||||
.run {
|
||||
if (exists()) {
|
||||
FileTransformer(this).block()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,91 +0,0 @@
|
||||
package moe.crx.ovrport.patch
|
||||
|
||||
import com.android.apksig.ApkSigner.SignerConfig
|
||||
import com.android.apksig.KeyConfig
|
||||
import org.bouncycastle.asn1.x500.X500Name
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
|
||||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.math.BigInteger
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
import java.security.PrivateKey
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.Certificate
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
object SignatureStore {
|
||||
private const val KS_PASSWORD = "password"
|
||||
private const val KS_ALIAS = "key"
|
||||
|
||||
private fun readSignature(file: File): SignerConfig {
|
||||
val ks = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||
|
||||
FileInputStream(file).use { stream ->
|
||||
ks.load(stream, KS_PASSWORD.toCharArray())
|
||||
}
|
||||
|
||||
val alias = ks.aliases().nextElement()
|
||||
|
||||
val key = ks.getKey(alias, KS_PASSWORD.toCharArray()) as PrivateKey
|
||||
val cert = ks.getCertificate(alias) as X509Certificate
|
||||
|
||||
return SignerConfig.Builder(cert.issuerX500Principal.name, KeyConfig.Jca(key), listOf(cert)).build()
|
||||
}
|
||||
|
||||
private fun generateCertificate(keyPair: KeyPair): Certificate {
|
||||
val now = System.currentTimeMillis()
|
||||
val startDate = Date(now)
|
||||
val expireDate = Calendar.getInstance().run {
|
||||
time = startDate
|
||||
add(Calendar.YEAR, 25)
|
||||
time
|
||||
}
|
||||
val dnName = UUID
|
||||
.randomUUID()
|
||||
.toString()
|
||||
.replace("-", "")
|
||||
.prependIndent("CN=")
|
||||
.let { X500Name(it) }
|
||||
val serial = BigInteger(64, SecureRandom())
|
||||
|
||||
val signer = JcaContentSignerBuilder("SHA256withRSA").setProvider(BouncyCastleProvider()).build(keyPair.private)
|
||||
|
||||
return JcaX509v3CertificateBuilder(dnName, serial, startDate, expireDate, dnName, keyPair.public).build(signer).let {
|
||||
JcaX509CertificateConverter().setProvider(BouncyCastleProvider()).getCertificate(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSignature(file: File) {
|
||||
val ks = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||
ks.load(null, KS_PASSWORD.toCharArray())
|
||||
|
||||
val keyPair = KeyPairGenerator
|
||||
.getInstance("RSA", BouncyCastleProvider())
|
||||
.apply { initialize(2048) }
|
||||
.generateKeyPair()
|
||||
val cert = generateCertificate(keyPair)
|
||||
|
||||
ks.setKeyEntry(KS_ALIAS, keyPair.private, KS_PASSWORD.toCharArray(), arrayOf(cert))
|
||||
|
||||
FileOutputStream(file).use { stream ->
|
||||
ks.store(stream, KS_PASSWORD.toCharArray())
|
||||
}
|
||||
}
|
||||
|
||||
fun getSignature(file: File): SignerConfig {
|
||||
if (!file.exists()) {
|
||||
createSignature(file)
|
||||
}
|
||||
|
||||
return readSignature(file)
|
||||
}
|
||||
}
|
||||
@@ -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,19 +0,0 @@
|
||||
package moe.crx.ovrport.utils
|
||||
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
|
||||
object HttpUtil {
|
||||
fun download(url: String): ByteArray {
|
||||
val uri = URI(url).toURL().openConnection() as HttpURLConnection
|
||||
|
||||
val bytes = uri.errorStream?.use {
|
||||
it.readBytes()
|
||||
} ?: uri.inputStream.use {
|
||||
it.readBytes()
|
||||
}
|
||||
|
||||
uri.disconnect()
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package moe.crx.ovrport.utils
|
||||
|
||||
import com.reandroid.json.JSONArray
|
||||
import com.reandroid.json.JSONObject
|
||||
|
||||
inline fun <reified T> JSONObject?.elem(name: String): T? {
|
||||
return this?.opt(name).takeIf { it is T } as T?
|
||||
}
|
||||
|
||||
inline fun <reified T> JSONArray?.elem(index: Int): T? {
|
||||
return this?.opt(index).takeIf { it is T } as T?
|
||||
}
|
||||
|
||||
inline fun <reified T> JSONObject?.take(name: String, block: T?.() -> T? = { this }): JSONObject? {
|
||||
this?.put(name, block(elem<T>(name)))
|
||||
return this
|
||||
}
|
||||
|
||||
inline fun <reified T> JSONArray?.take(index: Int, block: T?.() -> T? = { this }): JSONArray? {
|
||||
this?.put(index, block(elem<T>(index)))
|
||||
return this
|
||||
}
|
||||
|
||||
inline fun <reified T> JSONObject?.takeEach(
|
||||
condition: T?.() -> Boolean = { true },
|
||||
block: T?.() -> T? = { this }
|
||||
): JSONObject? {
|
||||
this?.keys()?.asSequence()?.toList()
|
||||
?.forEach { i ->
|
||||
elem<T>(i).let {
|
||||
if (condition(it)) {
|
||||
block(it)?.let { value -> put(i, value) }
|
||||
?: remove(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
inline fun <reified T> JSONArray?.takeEach(
|
||||
condition: T?.() -> Boolean = { true },
|
||||
block: T?.() -> T? = { this }
|
||||
): JSONArray? {
|
||||
this?.length()?.let { (it - 1).downTo(0) }
|
||||
?.forEach { i ->
|
||||
elem<T>(i).let {
|
||||
if (condition(it)) {
|
||||
block(it)?.let { value -> put(i, value) }
|
||||
?: remove(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
inline fun <reified T> JSONObject?.elemEach(condition: T?.() -> Boolean = { true }): List<T> {
|
||||
return this?.keys()?.asSequence()?.toList()
|
||||
?.mapNotNull { i -> elem<T>(i) }
|
||||
?.filter { condition(it) }
|
||||
?.toList()
|
||||
?: listOf()
|
||||
}
|
||||
|
||||
inline fun <reified T> JSONArray?.elemEach(condition: T?.() -> Boolean = { true }): List<T> {
|
||||
return this?.length()?.let { (it - 1).downTo(0) }
|
||||
?.mapNotNull { i -> elem<T>(i) }
|
||||
?.filter { condition(it) }
|
||||
?.toList()
|
||||
?: listOf()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user