Release 1.2.0

105 commits
This commit is contained in:
Mathew Tkachuk committed 2025-12-12 16:16:59 +03:00
1 parent 4ff09996ba
commit a939e52939
99 files changed
+3525 -1788

No files matched your search

+10 -28
View File
@@ -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.
+14 -15
View File
@@ -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
)
}
}
}
@@ -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,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,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 = {}
)
}
}
@@ -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,
)
}
}
)
}
@@ -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,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,4 +1,4 @@
package moe.crx.ovrport.ui.theme
package moe.crx.overport.app.theme
import androidx.compose.ui.graphics.Color
@@ -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,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()
}
},
)
}
}
}