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
+3420 -1683

No files matched your search

+56
View File
@@ -35,6 +35,31 @@ jobs:
path: composeApp/build/outputs/apk/release/*.apk
retention-days: 30
build-cli:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Build CLI package
run: ./gradlew :overportcli:shadowJar
shell: bash
- name: Upload CLI artifacts
uses: actions/upload-artifact@v4
with:
name: cli-jar
path: overportcli/build/libs/*
retention-days: 30
build-desktop-windows:
runs-on: windows-latest
@@ -99,3 +124,34 @@ jobs:
path: composeApp/build/compose/jars/*
retention-days: 30
build-desktop-macos:
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build Desktop package
run: ./gradlew :composeApp:packageReleaseDistributionForCurrentOS
shell: bash
- name: Build JAR package
run: ./gradlew :composeApp:packageUberJarForCurrentOS
shell: bash
- name: Upload Desktop artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-macos
path: composeApp/build/compose/binaries/main-release/**/*
retention-days: 30
+2
View File
@@ -20,3 +20,5 @@ captures
*.apk
composeApp/release/output-metadata.json
*.salive
workspace
ignore
+7 -25
View File
@@ -9,6 +9,7 @@ plugins {
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.composeHotReload)
}
kotlin {
@@ -28,6 +29,7 @@ kotlin {
implementation(libs.androidx.activity.compose)
}
commonMain.dependencies {
implementation(project(":overportcli"))
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
@@ -38,10 +40,7 @@ kotlin {
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(compose.materialIconsExtended)
implementation(libs.android.tools.build)
implementation(libs.bouncycastle.pkix)
implementation(libs.kotlinx.serialization.json)
implementation(fileTree("libs"))
}
desktopMain.dependencies {
implementation(compose.desktop.currentOs)
@@ -51,11 +50,11 @@ kotlin {
}
android {
namespace = "moe.crx.ovrport"
namespace = "moe.crx.overport.app"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
applicationId = "moe.crx.ovrport"
applicationId = "moe.crx.overport.app"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = appVersion.split('.').run { get(0).toInt() * 1000000 + get(1).toInt() * 1000 + get(2).toInt() }
@@ -89,18 +88,19 @@ dependencies {
compose.desktop {
application {
mainClass = "moe.crx.ovrport.MainKt"
mainClass = "moe.crx.overport.app.ComposeMainKt"
buildTypes {
release {
proguard {
configurationFiles.from("proguard-rules.pro")
isEnabled = false
}
}
}
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Exe, TargetFormat.AppImage)
targetFormats(TargetFormat.Dmg, TargetFormat.Exe, TargetFormat.Deb, TargetFormat.Rpm)
packageName = "overport"
packageVersion = appVersion
@@ -120,21 +120,3 @@ compose.desktop {
}
}
}
tasks.withType<org.gradle.jvm.tasks.Jar> {
// Need for valid signature check
exclude(
"META-INF/*.SF",
"META-INF/*.DSA",
"META-INF/*.RSA",
"META-INF/*.EC"
)
exclude(
"META-INF/INDEX.LIST",
"META-INF/DEPENDENCIES",
"META-INF/LICENSE*",
"META-INF/NOTICE*"
)
}
@@ -2,7 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:allowBackup="true"
@@ -10,16 +10,15 @@
android:label="@string/app_name"
android:supportsRtl="true"
android:extractNativeLibs="true"
android:theme="@style/Theme.Overport"
android:theme="@style/Theme.OverportSplash"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:icon="@mipmap/ic_launcher"
android:exported="true"
android:theme="@style/Theme.Overport">
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<meta-data android:name="pico.vr.position" android:value="near"/>
</activity>
@@ -0,0 +1,17 @@
package moe.crx.overport.app
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import moe.crx.overport.patches.IconResizer
import java.io.ByteArrayOutputStream
object BitmapIconResizer : IconResizer {
override fun resize(bytes: ByteArray, width: Int, height: Int): ByteArray {
val baos = ByteArrayOutputStream()
BitmapFactory.decodeByteArray(bytes, 0, bytes.size).apply {
reconfigure(width, height, Bitmap.Config.ARGB_8888)
}.compress(Bitmap.CompressFormat.PNG, 100, baos)
return baos.toByteArray()
}
}
@@ -0,0 +1,90 @@
package moe.crx.overport.app
import android.annotation.SuppressLint
import android.graphics.Color
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import moe.crx.overport.app.composables.AppContent
import moe.crx.overport.app.model.MainViewModel
import java.io.InputStream
import java.io.OutputStream
class MainActivity : ComponentActivity() {
companion object {
const val APK_MIMETYPE = "application/vnd.android.package-archive"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTheme(R.style.Theme_Overport)
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
)
setContent {
ActivityContent(this)
}
}
@SuppressLint("Recycle")
@Composable
private fun ActivityContent(activity: MainActivity) {
val viewModel: MainViewModel = viewModel { MainViewModel(activity.cacheDir, BitmapIconResizer) }
val openFlow = remember { MutableSharedFlow<Pair<String, InputStream>?>() }
val saveFlow = remember { MutableSharedFlow<OutputStream?>() }
val scope = rememberCoroutineScope()
val openApkFile = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri == null) {
scope.launch {
openFlow.emit(null)
}
return@rememberLauncherForActivityResult
}
val stream = activity.contentResolver.openInputStream(uri) ?: return@rememberLauncherForActivityResult
val fileName = uri.lastPathSegment?.substringAfter(':') ?: "application.apk"
scope.launch {
openFlow.emit(fileName to stream)
}
}
val saveApkFile = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument(APK_MIMETYPE)
) { uri ->
if (uri == null) {
scope.launch {
openFlow.emit(null)
}
return@rememberLauncherForActivityResult
}
val stream = activity.contentResolver.openOutputStream(uri) ?: return@rememberLauncherForActivityResult
scope.launch {
saveFlow.emit(stream)
}
}
AppContent(
viewModel = viewModel,
openFile = { openApkFile.launch(arrayOf(APK_MIMETYPE)) },
saveFile = { name -> saveApkFile.launch(name) },
openFlow = openFlow,
saveFlow = saveFlow,
)
}
}
@@ -0,0 +1,21 @@
package moe.crx.overport.app
import android.graphics.BitmapFactory
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
actual fun getPlatform(): Platform = AndroidPlatform("Android ${Build.VERSION.SDK_INT}")
actual fun ByteArray.decodeBitmap(): ImageBitmap {
return BitmapFactory.decodeByteArray(this, 0, size).asImageBitmap()
}
@Composable
actual fun screenWidthDp(): Dp {
return LocalConfiguration.current.screenWidthDp.dp
}
@@ -1,119 +0,0 @@
package moe.crx.ovrport
import android.graphics.BitmapFactory
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.asImageBitmap
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
import moe.crx.ovrport.patch.CantDecodeApkException
import moe.crx.ovrport.patch.CantUpdateOverportException
import moe.crx.ovrport.patch.Constants
import moe.crx.ovrport.ui.AppContent
import moe.crx.ovrport.ui.MainViewModel
import org.jetbrains.compose.resources.stringResource
import overportapp.composeapp.generated.resources.*
import overportapp.composeapp.generated.resources.Res
import overportapp.composeapp.generated.resources.apk_file_exported
import overportapp.composeapp.generated.resources.cant_read_apk
import overportapp.composeapp.generated.resources.cant_update_overport
import java.io.FileInputStream
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ActivityContent(this)
}
}
}
@Composable
private fun ActivityContent(activity: MainActivity) {
val viewModel: MainViewModel = viewModel { MainViewModel() }
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val cantReadApkMessage = stringResource(Res.string.cant_read_apk)
val apkFileExportedMessage = stringResource(Res.string.apk_file_exported)
val cantUpdateOverportMessage = stringResource(Res.string.cant_update_overport)
val unknownErrorMessage = stringResource(Res.string.unknown_error)
val openApkFile = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
coroutineScope.launch {
if (it != null) {
val stream = activity.contentResolver.openInputStream(it) ?: return@launch
val fileName = it.lastPathSegment?.substringAfter(':') ?: "game"
stream.use { s ->
try {
viewModel.import(activity.cacheDir, fileName, s)
} catch (_: CantDecodeApkException) {
viewModel.cancel()
snackbarHostState.showSnackbar(cantReadApkMessage)
} catch (_: CantUpdateOverportException) {
viewModel.cancel()
snackbarHostState.showSnackbar(cantUpdateOverportMessage)
} catch (_: Throwable) {
viewModel.cancel()
snackbarHostState.showSnackbar(unknownErrorMessage)
}
}
}
}
}
val saveApkFile =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument(Constants.APK_MIMETYPE)) {
coroutineScope.launch {
if (it != null) {
val stream = activity.contentResolver.openOutputStream(it) ?: return@launch
stream.use { s ->
viewModel.export(s)
launch {
snackbarHostState.showSnackbar(apkFileExportedMessage)
}
}
}
viewModel.cancel()
}
}
AppContent(
viewModel = viewModel.apply {
setAppIconConverter {
FileInputStream(it).use { stream ->
BitmapFactory.decodeStream(stream).asImageBitmap()
}
}
},
snackbarHostState = snackbarHostState,
onOpen = {
coroutineScope.launch {
openApkFile.launch(arrayOf(Constants.APK_MIMETYPE))
}
},
onConfirm = {
coroutineScope.launch {
viewModel.process(it)
saveApkFile.launch(viewModel.patchedName())
}
},
onCancel = {
coroutineScope.launch {
viewModel.cancel()
}
},
)
}
@@ -1,5 +0,0 @@
package moe.crx.ovrport
import android.os.Build
actual fun getPlatform(): Platform = AndroidPlatform("Android ${Build.VERSION.SDK_INT}")
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.OverportSplash" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
</style>
<style name="Theme.Overport" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@android:color/transparent</item>
</style>
@@ -1,4 +1,26 @@
<resources>
<string name="select_a_file">Select a file</string>
<string name="replace_icon_with_icon">Use game icon for replacement</string>
<string name="replace_icon_with_cover">Use game cover for replacement</string>
<string name="progress_preparing">Preparing libraries…</string>
<string name="progress_decompiling">Decompiling APK…</string>
<string name="progress_patching">Applying patches…</string>
<string name="progress_compiling">Compiling APK…</string>
<string name="progress_cleaning">Cleaning up…</string>
<string name="patcher_nav_item">Patcher</string>
<string name="select_version_title">Select version</string>
<string name="you_have_no_versions">You have no versions installed and overport release index is not available.
Check your internet connection.
</string>
<string name="tag_experimental">experimental</string>
<string name="tag_latest">latest</string>
<string name="tag_custom">custom</string>
<string name="status_installed">Installed</string>
<string name="status_installed_incompatible">Installed but incompatible</string>
<string name="status_not_installed">Not installed</string>
<string name="status_not_installed_incompatible">Incompatible with app version</string>
<string name="action_confirm">Confirm</string>
<string name="action_cancel">Cancel</string>
<string name="select_apk_to_begin">Select an APK to begin</string>
<string name="or_drag_and_drop_it">(or drag and drop it)</string>
<string name="decoding_apk">Processing APK…</string>
@@ -13,4 +35,25 @@
<string name="status_has_problems">Has problems</string>
<string name="status_not_working">Not working</string>
<string name="status_loading">Loading…</string>
<string name="use_at_your_risk">Not recommended, use at your own risk</string>
<string name="patch_copy_libraries">Copy ovrport libraries</string>
<string name="patch_copy_ovrplugin_vrapi">Copy OVRPlugin if VrApi is present</string>
<string name="patch_fix_min_android_sdk">Fix minimal Android SDK</string>
<string name="patch_generate_config">Generate ovrport config</string>
<string name="patch_remove_localized_names">Remove localized app names</string>
<string name="patch_clean_up_frida">Clean up Frida leftovers</string>
<string name="patch_oculus_unity">Patch Oculus detection for Unity</string>
<string name="patch_oculus_unreal">Patch Oculus detection for Unreal</string>
<string name="patch_vr_metadata">Pico/YVR/Quest metadata</string>
<string name="patch_launcher_entry">Fix launcher icon entry</string>
<string name="patch_remove_uses_library">Remove uses-library</string>
<string name="patch_fix_unreal_crash">Fix Unreal Engine 4 crash</string>
<string name="patch_meta_xr_audio">Patch Meta XR Audio</string>
<string name="patch_mark_as_debuggable">Mark application as debuggable</string>
<string name="patch_mark_allow_backup">Mark application to allow backup</string>
<string name="patch_replace_icon_label">Replace application label and icon</string>
<string name="patch_remove_unreal_force_quit">Remove Unreal's ForceQuit</string>
<string name="patch_force_passthrough">Force enable passthrough</string>
<string name="patch_disable_space_warp">Disable application space warp if used</string>
<string name="patch_disable_controller_offset">Disable controller tracking offset</string>
</resources>
@@ -0,0 +1,20 @@
package moe.crx.overport.app
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.unit.Dp
interface Platform {
val name: String
}
class JVMPlatform(override val name: String) : Platform
class AndroidPlatform(override val name: String) : Platform
expect fun getPlatform(): Platform
expect fun ByteArray.decodeBitmap(): ImageBitmap
@Composable
expect fun screenWidthDp(): Dp
@@ -0,0 +1,112 @@
package moe.crx.overport.app.composables
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.outlined.Build
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.withContext
import moe.crx.overport.app.model.GithubRelease
import moe.crx.overport.app.model.MainViewModel
import moe.crx.overport.app.theme.OverportTheme
import moe.crx.overport.versions.VersionManager
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import overportapp.composeapp.generated.resources.Res
import overportapp.composeapp.generated.resources.overport
import overportapp.composeapp.generated.resources.patcher_nav_item
import overportapp.composeapp.generated.resources.update_available
import java.io.InputStream
import java.io.OutputStream
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppContent(
viewModel: MainViewModel,
openFile: () -> Unit,
saveFile: (String) -> Unit,
openFlow: MutableSharedFlow<Pair<String, InputStream>?>,
saveFlow: MutableSharedFlow<OutputStream?>,
) {
OverportTheme(darkTheme = true) {
val snackbarHostState = remember { SnackbarHostState() }
val patcherNavItem =
BottomNavItem(stringResource(Res.string.patcher_nav_item), Icons.Filled.Build, Icons.Outlined.Build) {
PatcherScreen(viewModel, snackbarHostState, openFile, saveFile, openFlow, saveFlow)
}
var versionToUpdate by remember { mutableStateOf<GithubRelease?>(null) }
var showAboutDialog by remember { mutableStateOf(false) }
val urlHandler = LocalUriHandler.current
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
versionToUpdate = viewModel.versionToUpdate()
}
}
DynamicScaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painterResource(Res.drawable.overport),
contentDescription = null,
modifier = Modifier.size(112.dp, 24.dp)
)
}
},
actions = {
val isUpdateAvailable =
versionToUpdate?.name != null && versionToUpdate?.name != VersionManager.VERSION
FadeVisibility(isUpdateAvailable) {
FilledTonalButton(
modifier = Modifier.padding(8.dp, 0.dp),
onClick = { versionToUpdate?.htmlUrl?.let { urlHandler.openUri(it) } }
) {
Icon(
Icons.Default.CloudDownload,
contentDescription = null,
)
Spacer(Modifier.width(8.dp))
Text(
stringResource(Res.string.update_available),
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
),
)
},
navigationItems = listOf(patcherNavItem),
itemsEnabled = !viewModel.working && !viewModel.isApkLoaded()
)
if (showAboutDialog) {
AlertDialog(
onDismissRequest = { showAboutDialog = false },
title = { Text("overport") },
text = { Text("about overport") }, // TODO
confirmButton = {
TextButton(onClick = { showAboutDialog = false }) {
Text("Close")
}
},
)
}
}
}
@@ -0,0 +1,103 @@
package moe.crx.overport.app.composables
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Widgets
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moe.crx.overport.app.decodeBitmap
import moe.crx.overport.app.util.ModifierUtil.rounded
import moe.crx.overport.config.getApplicationInfo
import org.jetbrains.compose.resources.stringResource
import overportapp.composeapp.generated.resources.Res
import overportapp.composeapp.generated.resources.unknown_package
import java.util.*
@Composable
fun ApplicationInfo(
applicationName: String? = "Application",
applicationPackage: String? = "com.company.application",
applicationVersion: String? = "1.0.0",
applicationIcon: ImageBitmap? = null,
) {
var iconIsLoading by rememberSaveable(applicationPackage) { mutableStateOf(true) }
var onlineIcon by rememberSaveable(applicationPackage) { mutableStateOf<ImageBitmap?>(null) }
var onlineLabel by rememberSaveable(applicationPackage) { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(applicationPackage) {
scope.launch {
withContext(Dispatchers.IO) {
runCatching {
val info = applicationPackage?.let { getApplicationInfo(it) }
onlineLabel = info?.displayName
val image = info?.run {
val associated = images.associateBy { it.imageType }
associated["APP_IMG_ICON"] ?: associated["APP_IMG_COVER_SQUARE"]
}
onlineIcon = image?.uri?.let { Base64.getDecoder().decode(it) }?.decodeBitmap()
}
iconIsLoading = false
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box {
FadeVisibility(!iconIsLoading) {
onlineIcon?.let {
Image(
it,
contentDescription = null,
modifier = Modifier.size(48.dp).rounded(8.dp)
)
} ?: run {
if (applicationIcon == null) {
Icon(
Icons.Default.Widgets,
contentDescription = null,
modifier = Modifier.size(48.dp)
)
} else {
Image(
applicationIcon,
contentDescription = null,
modifier = Modifier.size(48.dp).rounded(8.dp)
)
}
}
}
FadeVisibility(iconIsLoading) {
CircularProgressIndicator(modifier = Modifier.size(48.dp))
}
}
Column(
modifier = Modifier.height(48.dp),
verticalArrangement = Arrangement.Center
) {
AnimatedContent(onlineLabel ?: applicationName) {
Text("$it ($applicationVersion)")
}
Text(
applicationPackage ?: stringResource(Res.string.unknown_package),
color = MaterialTheme.colorScheme.secondary
)
}
}
}
@@ -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,30 +16,34 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import moe.crx.ovrport.AndroidPlatform
import moe.crx.ovrport.getPlatform
import moe.crx.overport.app.AndroidPlatform
import moe.crx.overport.app.getPlatform
import moe.crx.overport.app.util.ModifierUtil.rounded
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import overportapp.composeapp.generated.resources.Res
import overportapp.composeapp.generated.resources.decoding_apk
import overportapp.composeapp.generated.resources.or_drag_and_drop_it
import overportapp.composeapp.generated.resources.select_apk_to_begin
import java.util.*
val isWinter = Calendar.getInstance().get(Calendar.MONTH).let { it == 0 || it == 1 || it == 11 }
@Composable
@Preview
fun StartingCard(
innerPadding: PaddingValues = PaddingValues(16.dp),
showLoading: Boolean = false,
currentString: String? = null,
onCardClick: () -> Unit = {}
) {
val strokeColor = LocalContentColor.current
Card(
modifier = Modifier
.padding(innerPadding)
.padding(16.dp)
.fillMaxSize()
.drawBehind {
if (!showLoading) {
drawRoundRect(
color = strokeColor,
style = Stroke(
@@ -54,8 +57,24 @@ fun StartingCard(
),
cornerRadius = CornerRadius(32.dp.toPx())
)
if (isWinter) {
drawRoundRect(
color = Color(0xFFEF5350),
style = Stroke(
width = 4.dp.toPx(),
pathEffect = PathEffect.dashPathEffect(
floatArrayOf(
16.dp.toPx(),
16.dp.toPx()
), 0f
)
),
cornerRadius = CornerRadius(32.dp.toPx())
)
}
.clip(RoundedCornerShape(32.dp))
}
}
.rounded(32.dp)
.clickable(!showLoading, onClick = onCardClick),
colors = CardDefaults.cardColors(
containerColor = Color.Transparent
@@ -70,7 +89,11 @@ fun StartingCard(
) {
if (!showLoading) {
Icon(
Icons.Default.Download,
if (isWinter) {
Icons.Default.Park
} else {
Icons.Default.Download
},
contentDescription = null,
modifier = Modifier
.padding(16.dp)
@@ -96,7 +119,7 @@ fun StartingCard(
.height(96.dp)
)
Text(
text = stringResource(Res.string.decoding_apk)
text = currentString ?: stringResource(Res.string.decoding_apk)
)
}
}
@@ -0,0 +1,76 @@
package moe.crx.overport.app.composables
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.Badge
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import moe.crx.overport.app.util.ModifierUtil.rounded
import moe.crx.overport.versions.OverportRelease
import moe.crx.overport.versions.VersionManager.Companion.isIncompatible
import org.jetbrains.compose.resources.stringResource
import overportapp.composeapp.generated.resources.*
@Composable
fun VersionListItem(release: OverportRelease, index: Int, isInstalled: Boolean, onSelected: () -> Unit) {
ListItem(
modifier = Modifier.rounded(8.dp).clickable(!isIncompatible(release)) {
onSelected()
},
headlineContent = {
FlowRow(
itemVerticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(release.version)
if (index == 0) {
Badge(containerColor = Color(0xFF1157CE)) {
Text(stringResource(Res.string.tag_latest))
}
}
if (release.isExperimental) {
Badge(containerColor = Color(0xFFB16300)) {
Text(stringResource(Res.string.tag_experimental))
}
}
if (release.isCustom) {
Badge(containerColor = Color(0xFFB60D6E)) {
Text(stringResource(Res.string.tag_custom))
}
}
}
},
supportingContent = {
if (isInstalled) {
if (!isIncompatible(release)) {
Text(stringResource(Res.string.status_installed))
} else {
Text(stringResource(Res.string.status_installed_incompatible))
}
} else {
if (!isIncompatible(release)) {
Text(stringResource(Res.string.status_not_installed))
} else {
Text(stringResource(Res.string.status_not_installed_incompatible))
}
}
},
trailingContent = {
if (!isInstalled && !isIncompatible(release)) {
Icon(
Icons.Default.Download,
contentDescription = null,
)
}
}
)
}
@@ -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,130 +0,0 @@
package moe.crx.ovrport.patch
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import kotlinx.serialization.json.Json
import moe.crx.ovrport.model.GithubIssues
import moe.crx.ovrport.patch.Constants.UNKNOWN_COMPATIBILITY
import moe.crx.ovrport.utils.HttpUtil.download
import org.jetbrains.compose.resources.StringResource
import overportapp.composeapp.generated.resources.*
import overportapp.composeapp.generated.resources.Res
import overportapp.composeapp.generated.resources.status_has_problems
import overportapp.composeapp.generated.resources.status_not_working
import overportapp.composeapp.generated.resources.status_unknown
import java.net.URLEncoder
private val json = Json { ignoreUnknownKeys = true }
object Constants {
const val APK_MIMETYPE = "application/vnd.android.package-archive"
const val OVRPORT_VERSION = "3.2.2"
const val LIBRARIES_URL = "https://files.crx.moe/ovrport/tracker/releases/download/$OVRPORT_VERSION/libraries.zip"
const val LIBRARIES_DIR = "libraries/$OVRPORT_VERSION"
const val RELEASES_URL = "https://api.github.com/repos/ovrport/app/releases"
const val ISSUES_URL = "https://api.github.com/search/issues?q=%s+repo:ovrport/tracker"
private const val TRACKER_URL = "https://github.com/ovrport/tracker/issues"
val UNKNOWN_COMPATIBILITY = CompatibilityStatusInfo(CompatibilityStatus.UNKNOWN, TRACKER_URL)
val LOADING_COMPATIBILITY = CompatibilityStatusInfo(CompatibilityStatus.LOADING, TRACKER_URL)
}
enum class CompatibilityStatus(
val labelName: String,
val icon: ImageVector,
val nameResource: StringResource,
val cardColor: Color,
val contentColor: Color,
) {
LOADING(
"Loading",
Icons.Default.Sync,
Res.string.status_loading,
Color(0xFF483f77),
Color(0xFFe6deff),
),
UNKNOWN(
"Unknown",
Icons.Default.QuestionMark,
Res.string.status_unknown,
Color(0xFF354479),
Color(0xFFDCE1FF),
),
NOT_WORKING(
"Not working",
Icons.Default.Close,
Res.string.status_not_working,
Color(0xFF73332f),
Color(0xFFffdad7),
),
HAS_PROBLEMS(
"Has problems",
Icons.Default.Warning,
Res.string.status_has_problems,
Color(0xFF5b4300),
Color(0xFFffdf9f),
),
PLAYABLE(
"Playable",
Icons.Default.Check,
Res.string.status_playable,
Color(0xFF2d4f1d),
Color(0xFFc4efac),
),
}
data class CompatibilityStatusInfo(
val status: CompatibilityStatus,
val url: String,
)
fun getCompatibilityStatus(
applicationName: String?,
applicationPackage: String?
): CompatibilityStatusInfo {
val pkg = applicationName ?: ""
val name = applicationPackage ?: ""
runCatching {
val issue = json
.decodeFromString<GithubIssues>(
String(
download(
Constants.ISSUES_URL.format(
URLEncoder.encode(
pkg,
Charsets.UTF_8
)
)
)
)
)
.items.firstOrNull { it.title.contains(pkg) || it.title.contains(name) }
?: json
.decodeFromString<GithubIssues>(
String(
download(
Constants.ISSUES_URL.format(
URLEncoder.encode(
name,
Charsets.UTF_8
)
)
)
)
)
.items.firstOrNull { it.title.contains(pkg) || it.title.contains(name) }
issue?.labels?.forEach { label ->
CompatibilityStatus.entries.forEach { status ->
if (status.labelName == label.name) {
return CompatibilityStatusInfo(status, issue.htmlUrl)
}
}
}
}
return UNKNOWN_COMPATIBILITY
}
@@ -1,5 +0,0 @@
package moe.crx.ovrport.patch
class CantUpdateOverportException : RuntimeException()
class CantDecodeApkException : RuntimeException()
@@ -1,248 +0,0 @@
package moe.crx.ovrport.patch
import com.reandroid.json.JSONArray
import com.reandroid.json.JSONObject
import moe.crx.ovrport.model.generateConfig
import moe.crx.ovrport.utils.*
object PatchStore {
val PATCHES = mutableListOf<Patch>()
init {
PATCHES += Patch("Copy ovrport libraries") {
selectWorkspace {
file.resolve("root/lib")
.listFiles()
?.forEach {
val arch = file.parentFile
?.parentFile
?.resolve(Constants.LIBRARIES_DIR)
?.resolve("lib")
?.resolve(it.name)
if (arch?.exists() == true) {
arch.copyRecursively(it, true)
}
}
}
}
PATCHES += Patch("Fix minimal Android SDK") {
selectManifest {
readJson().takeNodesEach({ named("manifest") }) {
takeNodesEach({ named("uses-sdk") }) {
takeAttributesEach({ named("minSdkVersion") }) {
take<Int>("data") { 29 }
}
}
}?.let {
writeJson(it)
}
}
selectSmali {
replace(
"invoke-virtual \\{(\\w+), (\\w+)\\}, Landroid/view/Window;->setDecorFitsSystemWindows\\(Z\\)V",
""
)
}
selectSmali("androidx/core/view/WindowCompat\$Api30Impl") {
replace(
"invoke-virtual \\{(\\w+), (\\w+)\\}, Landroid/view/Window;->setDecorFitsSystemWindows\\(Z\\)V",
""
)
}
selectSmali("androidx/core/view/WindowInsetsCompat\$TypeImpl30") {
replace(
"(toPlatformType\\(I\\)I.*?)\\.locals (\\w+)",
"$0\nconst v0, 0x0\nreturn v0"
)
}
selectSmali("androidx/core/view/WindowInsetsCompat\$Impl30") {
replace(
"(getInsets\\(I\\)Landroidx/core/graphics/Insets;.*?)\\.locals (\\w+)",
"$0\nsget-object p0, Landroidx/core/graphics/Insets;->NONE:Landroidx/core/graphics/Insets;\nreturn-object p0"
)
replace("(isVisible\\(I\\)Z.*?)\\.locals (\\w+)", "$0\nconst/4 p0, 0\nreturn p0")
}
selectSmali("com/epicgames/unreal/GameActivity") {
replace(
"const-string \\w+?, \\\"vibrator_manager\\\".*?iput-object \\w+?, \\w+?, Lcom/epicgames/unreal/GameActivity;->\\w+?:Landroid/os/Vibrator;",
""
)
}
}
PATCHES += Patch("Generate ovrport config") {
selectLibrary("libfrda.config.so") {
readJson()?.let { generateConfig(it.toString()) }?.let {
createLibrary("liboverport.config.so") {
writeJson(JSONObject(it))
}
}
}
}
PATCHES += Patch("Remove localized app names") {
selectResources {
readJson().take<JSONArray>("packages") {
takeEach<JSONObject> {
take<JSONArray>("specs") {
takeEach<JSONObject> {
take<JSONArray>("types") {
takeEach<JSONObject> {
val configLanguage = elem<JSONObject>("config").elem<String>("language")
take<JSONArray>("entries") {
takeEach<JSONObject> {
this.takeIf {
elem<String>("entry_name") != "app_name" || configLanguage == null
}
}
}
}
}
}
}
}
}?.let {
writeJson(it)
}
}
}
PATCHES += Patch("Clean up Frida") {
selectLibrary("libfrda.so") {
file.delete()
}
selectLibrary("libfrda.config.so") {
file.delete()
}
selectLibrary("libscript.so") {
file.delete()
}
selectManifest {
readJson().takeNodesEach(({ named("manifest") })) {
takeNodesEach({ named("application") }) {
takeNodesEach({ named("activity") }) {
attributeName()?.replace('.', '/')?.let {
selectSmali(it) {
replace(
"const-string (\\w+), \\\"frda\\\"\\s+?invoke-static \\{\\1\\}, Ljava/lang/System;->loadLibrary\\(Ljava/lang/String;\\)V",
""
)
}
}
this
}
}
}
}
}
PATCHES += Patch("Patch Oculus detection for Unity") {
selectSmali("com/unity/oculus/OculusUnity") {
replace(
"(getIsOnOculusHardware\\(\\)Z.*?)return (\\w+)",
"$1\nconst/4 $2, 0x1\nreturn $2"
)
}
}
PATCHES += Patch("Patch Oculus detection for Unreal") {
selectSmali(
"com/epicgames/ue4/GameActivity",
"com/epicgames/unreal/GameActivity",
) {
replace(
"sget-object (\\w+), Landroid/os/Build;->MANUFACTURER:Ljava/lang/String;",
"const-string $1, \"Oculus\""
)
replace(
"sget-object (\\w+), Landroid/os/Build;->MODEL:Ljava/lang/String;",
"const-string $1, \"Quest 2\""
)
}
}
PATCHES += Patch("Pico/YVR/Quest metadata") {
selectManifest {
readJson().takeNodesEach(({ named("manifest") })) {
takeNodesEach({ named("application") }) {
takeNodesEach({ named("meta-data") }) {
if (attributeName() == "com.oculus.supportedDevices") null else this
}
takeNodes {
this
?.put(createMetadata("pvr.app.type", "vr"))
?.put(createMetadata("com.yvr.intent.category.VR", "vr_only"))
?.put(createMetadata("com.oculus.supportedDevices", "all"))
}
}
}?.let {
writeJson(it)
}
}
}
PATCHES += Patch("Fix launcher icon entry") {
selectManifest {
readJson().takeNodesEach(({ named("manifest") })) {
takeNodesEach({ named("application") }) {
takeNodesEach({ named("activity") }) {
takeNodesEach({ named("intent-filter") }) {
val hasIntent = elem<JSONArray>("nodes").elemEach<JSONObject> { named("category") }.map {
when (it.attributeName()) {
"com.oculus.intent.category.VR" -> true
"android.intent.category.LAUNCHER" -> true
else -> false
}
}.reduceOrNull { l, r -> l || r } ?: false
takeNodes {
if (!hasIntent) this else {
JSONArray()
.put(createAction("android.intent.action.MAIN"))
.put(createCategory("android.intent.category.LAUNCHER"))
.put(createCategory("com.oculus.intent.category.VR"))
.put(createCategory("com.yvr.intent.category.VR"))
}
}
}
}
}
}?.let {
writeJson(it)
}
}
}
PATCHES += Patch("Remove uses-library") {
selectManifest {
readJson().takeNodesEach({ named("manifest") }) {
takeNodesEach({ named("application") }) {
takeNodesEach({ named("uses-library") }) { null }
takeNodesEach({ named("uses-native-library") }) { null }
}
}?.let {
writeJson(it)
}
}
}
PATCHES += Patch("Fix Unreal Engine 4 crash") {
createSmali("com/unity3d/player/UnityPlayer") {
if (readText().isBlank()) {
append(".class public Lcom/unity3d/player/UnityPlayer;")
append(".super Ljava/lang/Object;")
append(".field public static currentActivity:Landroid/app/Activity;")
}
}
selectSmali("com/epicgames/ue4/GameActivity") {
replace(
"sput-object (\\w+), Lcom\\/epicgames\\/ue4\\/GameActivity;->\\w+:Lcom\\/epicgames\\/ue4\\/GameActivity;",
"$0\nsput-object $1, Lcom/unity3d/player/UnityPlayer;->currentActivity:Landroid/app/Activity;"
)
}
}
PATCHES += Patch("Patch MetaXRAudioWwise") {
selectLibrary("libMetaXRAudioWwise.so") {
replaceHex(
"57 D0 3B D5 40 EB FF F0 00 0C 2D 91 E8 16 40 F9 A8 83 1F F8 DC 7F 0B 94 00 01 00 B4 E3 03 00 AA 21 EB FF B0",
"57 D0 3B D5 20 00 80 D2 1F 20 03 D5 E8 16 40 F9 A8 83 1F F8 1F 20 03 D5 00 01 00 B4 03 00 80 D2 21 EB FF B0"
)
}
}
}
}
@@ -1,225 +0,0 @@
package moe.crx.ovrport.patch
import com.android.apksig.ApkSigner
import com.reandroid.apk.*
import com.reandroid.archive.FileInputSource
import moe.crx.ovrport.utils.HttpUtil
import org.jf.baksmali.Baksmali
import org.jf.baksmali.BaksmaliOptions
import org.jf.dexlib2.Opcodes
import org.jf.dexlib2.VersionMap
import org.jf.dexlib2.dexbacked.DexBackedDexFile
import org.jf.dexlib2.dexbacked.raw.HeaderItem
import org.jf.smali.Smali
import org.jf.smali.SmaliOptions
import java.io.*
import java.nio.file.Files
import java.nio.file.StandardCopyOption.REPLACE_EXISTING
import java.util.zip.ZipInputStream
data class PatcherContext(
val patcherDirectory: File,
val dataDir: File = patcherDirectory.resolve("dec"),
val fileName: String = "working",
val inputApk: File = dataDir.resolve("$fileName.apk"),
val outputApk: File = dataDir.resolve("$fileName.output.apk"),
val workingDir: File = dataDir.resolve(fileName),
var workingApk: ApkModule? = null,
)
fun PatcherContext.prepare(stream: InputStream) {
val librariesDir = patcherDirectory.resolve(Constants.LIBRARIES_DIR)
if (!librariesDir.exists()) {
val downloadedArchive = runCatching { HttpUtil.download(Constants.LIBRARIES_URL) }.getOrElse {
librariesDir.deleteRecursively()
throw CantUpdateOverportException()
}
librariesDir.mkdirs()
ZipInputStream(downloadedArchive.inputStream()).use { zip ->
generateSequence { zip.nextEntry }
.forEach {
if (it.isDirectory) {
librariesDir.resolve(it.name).mkdirs()
} else {
zip.copyTo(FileOutputStream(librariesDir.resolve(it.name)))
}
}
}
}
dataDir.mkdirs()
workingDir.deleteRecursively()
inputApk.delete()
outputApk.delete()
Files.copy(stream, inputApk.toPath(), REPLACE_EXISTING)
val apk = runCatching { ApkModule.loadApkFile(inputApk) }.getOrElse {
throw CantDecodeApkException()
}
val decoder = ApkModuleJsonDecoder(apk).apply {
dexDecoder = DexDecoder { dexFile, dir ->
val bytes = ByteArrayOutputStream().apply {
dexFile.write(this)
}.toByteArray()
val version = HeaderItem.getVersion(bytes, 0)
val api = VersionMap.mapDexVersionToApi(version)
val opcodes = Opcodes.forApi(api)
val classesName = dexFile.dexNumber.let { if (it != 0) "classes$it" else "classes" }
val smaliDir = dir.resolve("smali").resolve(classesName)
val options = BaksmaliOptions().apply {
localsDirective = true
sequentialLabels = true
skipDuplicateLineNumbers = true
apiLevel = api
}
Baksmali.disassembleDexFile(DexBackedDexFile(opcodes, bytes), smaliDir, 4, options)
}
}
decoder.sanitizeFilePaths()
decoder.decode(workingDir)
workingApk = apk
}
fun PatcherContext.patch(patches: List<Patch>) {
patches.forEach {
it.executor(PatchExecutor(workingDir))
}
val encoder = ApkModuleJsonEncoder().apply {
dexEncoder = DexEncoder { _, dir ->
dir
.resolve("smali")
.listFiles()
?.filter { file -> file.isDirectory }
?.map {
dir.resolve(".cache").mkdirs()
val file = dir.resolve(".cache").resolve("${it.name}.dex")
val smaliOptions = SmaliOptions().apply {
outputDexFile = file.absolutePath
jobs = 4
apiLevel = 29
}
Smali.assemble(smaliOptions, it.absolutePath)
FileInputSource(file, file.name)
}
?.toList()
}
}
encoder.scanDirectory(workingDir)
encoder.apkModule.run {
zipEntryMap.autoSortApkFiles()
writeApk(outputApk)
close()
}
val signatureDir = patcherDirectory.resolve("signatures")
signatureDir.mkdirs()
val signature = (appPackage() ?: "default")
.plus(".keystore")
.let { signatureDir.resolve(it) }
.let { SignatureStore.getSignature(it) }
val tempFile = outputApk
.parentFile
?.resolve(outputApk.name + ".temp")
?.let {
it.delete()
outputApk.copyTo(it)
}
try {
ApkSigner
.Builder(listOf(signature))
.setAlignFileSize(true)
.setMinSdkVersion(29)
.setInputApk(tempFile)
.setOutputApk(outputApk)
.build()
.sign()
} catch (e: Exception) {
e.printStackTrace()
}
tempFile?.delete()
}
fun PatcherContext.export(stream: OutputStream) {
Files.copy(outputApk.toPath(), stream)
}
fun PatcherContext.cleanup() {
workingDir.deleteRecursively()
workingApk?.close()
workingApk = null
inputApk.delete()
outputApk.delete()
}
fun PatcherContext.appPackage(): String? {
return workingApk?.androidManifest?.packageName
}
fun PatcherContext.appVersion(): String? {
return workingApk?.androidManifest?.versionName
?: workingApk?.androidManifest?.versionCode?.toString()
}
fun PatcherContext.appName(): String? {
return workingApk?.androidManifest?.applicationLabelString
?: workingApk
?.tableBlock
?.getEntries(workingApk?.androidManifest?.applicationLabelReference ?: 0)
?.asSequence()
?.firstOrNull()
?.valueAsString
}
fun PatcherContext.appIcon(): File? {
val iconId = workingApk?.androidManifest?.iconResourceId?.takeIf { it != 0 }
?: workingApk?.androidManifest?.roundIconResourceId?.takeIf { it != 0 }
?: return null
val result = workingApk?.listResFiles(iconId, null)
?: return null
// TODO layered adaptive icons support
arrayOf(
"-xxxhdpi",
"-xxhdpi",
"-xhdpi",
"-hdpi",
"-mdpi",
"-ldpi",
"-anydpi",
""
).forEach { dpi ->
arrayOf(".webp", ".png").forEach { ext ->
result.firstOrNull { it.filePath.contains(dpi) && it.filePath.endsWith(ext) }?.let {
workingDir.resolve("root").resolve(it.filePath).run {
if (exists()) {
return this
}
}
}
}
}
return null
}
@@ -1,132 +0,0 @@
package moe.crx.ovrport.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import moe.crx.ovrport.AndroidPlatform
import moe.crx.ovrport.getCurrentVersion
import moe.crx.ovrport.getPlatform
import moe.crx.ovrport.model.GithubRelease
import moe.crx.ovrport.patch.Constants
import moe.crx.ovrport.patch.Patch
import moe.crx.ovrport.ui.theme.OverportTheme
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import overportapp.composeapp.generated.resources.Res
import overportapp.composeapp.generated.resources.overport
import overportapp.composeapp.generated.resources.update_available
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppContent(
viewModel: MainViewModel,
snackbarHostState: SnackbarHostState,
onOpen: () -> Unit,
onCancel: () -> Unit,
onConfirm: (List<Patch>) -> Unit
) {
OverportTheme(darkTheme = true) {
var versionToUpdate by remember { mutableStateOf<GithubRelease?>(null) }
val urlHandler = LocalUriHandler.current
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
versionToUpdate = viewModel.versionToUpdate()
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = Modifier
.fillMaxSize()
.then(
if (getPlatform() is AndroidPlatform) {
Modifier.clip(RoundedCornerShape(32.dp))
} else Modifier
),
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painterResource(Res.drawable.overport),
contentDescription = null,
modifier = Modifier.size(112.dp, 24.dp)
)
Text(
Constants.OVRPORT_VERSION,
modifier = Modifier.padding(8.dp),
fontSize = 18.sp,
)
}
},
actions = {
FadeVisibility(versionToUpdate?.name != null && versionToUpdate?.name != getCurrentVersion()) {
Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clip(
RoundedCornerShape(16.dp)
).clickable {
versionToUpdate?.htmlUrl?.let { urlHandler.openUri(it) }
}) {
Icon(
Icons.Default.CloudDownload,
contentDescription = null,
modifier = Modifier.padding(8.dp),
)
Text(
stringResource(Res.string.update_available),
fontSize = 18.sp,
modifier = Modifier.padding(0.dp, 8.dp, 8.dp, 8.dp),
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
),
)
}
) { innerPadding ->
val inProgress = viewModel.working
val isPatcherVisible = viewModel.isApkLoaded() && !inProgress
FadeVisibility(!isPatcherVisible) {
StartingCard(innerPadding, inProgress) {
onOpen()
}
}
FadeVisibility(isPatcherVisible) {
ApplicationInfoCard(
innerPadding,
viewModel.currentAppName(),
viewModel.currentAppPackage(),
viewModel.currentAppVersion(),
viewModel.currentAppIcon(),
onCancel = {
onCancel()
},
onConfirm = {
onConfirm(it)
}
)
}
}
}
}
@@ -1,52 +0,0 @@
package moe.crx.ovrport.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Widgets
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import overportapp.composeapp.generated.resources.Res
import overportapp.composeapp.generated.resources.unknown_package
@Composable
fun ApplicationInfo(
applicationName: String? = "Application",
applicationPackage: String? = "com.company.application",
applicationVersion: String? = "1.0.0",
applicationIcon: ImageBitmap? = null,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (applicationIcon == null) {
Icon(
Icons.Default.Widgets,
contentDescription = null,
modifier = Modifier.size(48.dp)
)
} else {
Image(
applicationIcon,
contentDescription = null,
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp))
)
}
Column(
modifier = Modifier.height(48.dp),
verticalArrangement = Arrangement.Center
) {
Text("$applicationName ($applicationVersion)")
Text(
applicationPackage ?: stringResource(Res.string.unknown_package),
)
}
}
}
@@ -1,103 +0,0 @@
package moe.crx.ovrport.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.unit.dp
import moe.crx.ovrport.patch.Patch
import moe.crx.ovrport.patch.PatchStore
import org.jetbrains.compose.ui.tooling.preview.Preview
@OptIn(ExperimentalLayoutApi::class)
@Composable
@Preview
fun ApplicationInfoCard(
innerPadding: PaddingValues = PaddingValues(16.dp),
applicationName: String? = "Application",
applicationPackage: String? = "",
applicationVersion: String? = "1.0.0",
applicationIcon: ImageBitmap? = null,
onCancel: () -> Unit = {},
onConfirm: (List<Patch>) -> Unit = {}
) {
Column(
modifier = Modifier.padding(innerPadding),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FlowRow(
modifier = Modifier.fillMaxWidth().padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
ApplicationInfo(applicationName, applicationPackage, applicationVersion, applicationIcon)
ApplicationStatus(applicationName, applicationPackage)
}
val patches = remember { mutableStateListOf(*PatchStore.PATCHES.toTypedArray()) }
fun togglePatch(patch: Patch) {
val checked = patches.contains(patch)
if (checked) {
patches.remove(patch)
} else {
patches.add(patch)
}
}
LazyColumn(
modifier = Modifier.weight(1f)
) {
items(PatchStore.PATCHES) {
ListItem(
headlineContent = {
Text(it.name)
},
supportingContent = {
it.desc?.let {
Text(it)
}
},
trailingContent = {
Checkbox(patches.contains(it), { _ ->
togglePatch(it)
})
},
modifier = Modifier.clickable {
togglePatch(it)
}
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
modifier = Modifier.weight(1f),
onClick = onCancel
) {
Text("Cancel")
}
Button(
modifier = Modifier.weight(1f),
onClick = {
onConfirm(patches)
}
) {
Text("Confirm")
}
}
}
}
@@ -1,57 +0,0 @@
package moe.crx.ovrport.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import moe.crx.ovrport.patch.Constants.LOADING_COMPATIBILITY
import moe.crx.ovrport.patch.getCompatibilityStatus
import org.jetbrains.compose.resources.stringResource
@Composable
fun ApplicationStatus(
applicationName: String? = "Application",
applicationPackage: String? = "",
) {
var statusInfo by remember(applicationPackage) { mutableStateOf(LOADING_COMPATIBILITY) }
val urlHandler = LocalUriHandler.current
LaunchedEffect(applicationPackage) {
withContext(Dispatchers.IO) {
statusInfo = getCompatibilityStatus(applicationName, applicationPackage)
}
}
Card(colors = CardDefaults.cardColors(statusInfo.status.cardColor, statusInfo.status.contentColor)) {
Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clip(
RoundedCornerShape(16.dp)
).clickable {
urlHandler.openUri(statusInfo.url)
}) {
Icon(
statusInfo.status.icon,
contentDescription = null,
modifier = Modifier.padding(16.dp),
)
Text(
stringResource(statusInfo.status.nameResource),
fontSize = 18.sp,
modifier = Modifier.padding(0.dp, 16.dp, 16.dp, 16.dp),
)
}
}
}
@@ -1,95 +0,0 @@
package moe.crx.ovrport.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.ImageBitmap
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import moe.crx.ovrport.model.GithubRelease
import moe.crx.ovrport.patch.*
import moe.crx.ovrport.utils.HttpUtil.download
import java.io.File
import java.io.InputStream
import java.io.OutputStream
private val json = Json { ignoreUnknownKeys = true }
class MainViewModel : ViewModel() {
private var patcherState by mutableStateOf<PatcherContext?>(null)
var working by mutableStateOf(false)
private set
private var fileName: String = "game"
private var imageConverter: ((File) -> ImageBitmap)? = null
private suspend fun usePatcher(block: PatcherContext.() -> Unit) {
working = true
withContext(Dispatchers.IO) {
patcherState?.block()
}
working = false
}
suspend fun import(dataDirectory: File, name: String, inputStream: InputStream) {
fileName = name
patcherState = PatcherContext(dataDirectory)
usePatcher { prepare(inputStream) }
}
fun patchedName(): String {
return fileName.substringBeforeLast('.').let { "$it.output.apk" }
}
suspend fun process(patches: List<Patch>) {
usePatcher { patch(patches) }
}
suspend fun export(outputStream: OutputStream) {
usePatcher { export(outputStream) }
}
suspend fun cancel() {
usePatcher { cleanup() }
fileName = "game"
patcherState = null
}
fun currentAppName(): String? {
return patcherState?.appName()
}
fun currentAppPackage(): String? {
return patcherState?.appPackage()
}
fun currentAppVersion(): String? {
return patcherState?.appVersion()
}
fun setAppIconConverter(converter: (File) -> ImageBitmap) {
imageConverter = converter
}
fun currentAppIcon(): ImageBitmap? {
return patcherState?.appIcon()?.let { file ->
imageConverter?.let {
it(file)
}
}
}
fun isApkLoaded(): Boolean {
return patcherState?.workingApk != null
}
fun versionToUpdate(): GithubRelease? {
return runCatching {
json
.decodeFromString<List<GithubRelease>>(String(download(Constants.RELEASES_URL)))
.reduceOrNull { left, right -> if (left.publishedAt > right.publishedAt) left else right }
}.getOrNull()
}
}
@@ -1,107 +0,0 @@
package moe.crx.ovrport.utils
import com.reandroid.json.JSONArray
import com.reandroid.json.JSONObject
fun JSONObject?.named(name: String?): Boolean {
return elem<String>("name") == name
}
fun JSONObject?.takeNodes(block: JSONArray?.() -> JSONArray? = { this }): JSONObject? {
return take("nodes", block)
}
fun JSONObject?.takeAttributes(block: JSONArray?.() -> JSONArray? = { this }): JSONObject? {
return take("attributes", block)
}
fun JSONObject?.takeNodesEach(
condition: JSONObject?.() -> Boolean = { true },
block: JSONObject?.() -> JSONObject? = { this }
): JSONObject? {
return takeNodes {
takeEach(condition, block)
}
}
fun JSONObject?.takeAttributesEach(
condition: JSONObject?.() -> Boolean = { true },
block: JSONObject?.() -> JSONObject? = { this }
): JSONObject? {
return takeAttributes {
takeEach(condition, block)
}
}
fun JSONObject?.attributeName(): String? {
return elem<JSONArray>("attributes")
.elemEach<JSONObject> { named("name") }
.firstOrNull()
.elem<String>("data")
}
fun createMetadata(name: String, value: String): JSONObject {
return JSONObject()
.put("node_type", "element")
.put("name", "meta-data")
.put(
"attributes",
JSONArray()
.put(
JSONObject()
.put("name", "name")
.put("id", 16842755)
.put("uri", "http://schemas.android.com/apk/res/android")
.put("prefix", "android")
.put("value_type", "STRING")
.put("data", name)
)
.put(
JSONObject()
.put("name", "value")
.put("id", 16842788)
.put("uri", "http://schemas.android.com/apk/res/android")
.put("prefix", "android")
.put("value_type", "STRING")
.put("data", value)
)
)
}
fun createCategory(value: String): JSONObject {
return JSONObject()
.put("node_type", "element")
.put("name", "category")
.put(
"attributes",
JSONArray()
.put(
JSONObject()
.put("name", "name")
.put("id", 16842755)
.put("uri", "http://schemas.android.com/apk/res/android")
.put("prefix", "android")
.put("value_type", "STRING")
.put("data", value)
)
)
}
fun createAction(value: String): JSONObject {
return JSONObject()
.put("node_type", "element")
.put("name", "action")
.put(
"attributes",
JSONArray()
.put(
JSONObject()
.put("name", "name")
.put("id", 16842755)
.put("uri", "http://schemas.android.com/apk/res/android")
.put("prefix", "android")
.put("value_type", "STRING")
.put("data", value)
)
)
}
@@ -0,0 +1,131 @@
package moe.crx.overport.app
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.draganddrop.awtTransferable
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import moe.crx.overport.app.composables.AppContent
import moe.crx.overport.app.model.MainViewModel
import moe.crx.overport.utils.DesktopUtil.defaultWorkspace
import moe.crx.overport.utils.ImageIOIconResizer
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import overportapp.composeapp.generated.resources.Res
import overportapp.composeapp.generated.resources.select_a_file
import overportapp.composeapp.generated.resources.window_icon
import java.awt.FileDialog
import java.awt.Frame
import java.awt.datatransfer.DataFlavor
import java.io.*
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
fun main(args: Array<String>) = application {
Window(
onCloseRequest = ::exitApplication,
title = "overport",
icon = painterResource(Res.drawable.window_icon),
) {
val dataDir = defaultWorkspace()
dataDir.mkdirs()
val selectFileString = stringResource(Res.string.select_a_file)
val viewModel: MainViewModel = viewModel { MainViewModel(dataDir, ImageIOIconResizer) }
val openFlow = remember { MutableSharedFlow<Pair<String, InputStream>?>() }
val saveFlow = remember { MutableSharedFlow<OutputStream?>() }
var lastOpenedDirectory by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
fun openFile(file: File?) {
if (file == null || !file.isFile) {
scope.launch {
openFlow.emit(null)
}
return
}
scope.launch {
lastOpenedDirectory = file.parentFile.absolutePath
openFlow.emit(file.name to FileInputStream(file))
}
}
val dragAndDropTarget = remember {
object : DragAndDropTarget {
override fun onDrop(event: DragAndDropEvent): Boolean {
val value: List<File>? = event.awtTransferable
.takeIf { it.isDataFlavorSupported(DataFlavor.javaFileListFlavor) }
?.getTransferData(DataFlavor.javaFileListFlavor)
?.let { it as? List<*> }
?.filterIsInstance<File>()
if (value?.size == 1) {
scope.launch {
openFile(value.first())
}
return true
}
return false
}
}
}
LaunchedEffect(args) {
if (args.isNotEmpty()) {
openFile(File(args.first()))
}
}
Box(
modifier = Modifier.fillMaxSize().dragAndDropTarget(
shouldStartDragAndDrop = { !viewModel.working && !viewModel.isApkLoaded() },
target = dragAndDropTarget
)
) {
AppContent(
viewModel = viewModel,
openFile = {
val fileDialog = FileDialog(Frame(), selectFileString, FileDialog.LOAD).apply {
directory = lastOpenedDirectory
isVisible = true
}
val file = fileDialog.file?.let { File(fileDialog.directory).resolve(it) }
openFile(file)
},
saveFile = { name ->
val fileDialog = FileDialog(Frame(), selectFileString, FileDialog.SAVE).apply {
directory = lastOpenedDirectory
file = name
isVisible = true
}
val file = fileDialog.file?.let { File(fileDialog.directory).resolve(it) }
scope.launch {
if (file == null) {
saveFlow.emit(null)
} else {
saveFlow.emit(FileOutputStream(file))
}
}
},
openFlow = openFlow,
saveFlow = saveFlow,
)
}
}
}
@@ -0,0 +1,23 @@
package moe.crx.overport.app
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.jetbrains.skia.Image
actual fun getPlatform(): Platform = JVMPlatform("Java ${System.getProperty("java.version")}")
actual fun ByteArray.decodeBitmap(): ImageBitmap {
return Image.makeFromEncoded(this).toComposeImageBitmap()
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
actual fun screenWidthDp(): Dp {
return (LocalWindowInfo.current.containerSize.width / LocalDensity.current.density).dp
}
@@ -1,3 +0,0 @@
package moe.crx.ovrport
actual fun getPlatform(): Platform = JVMPlatform("Java ${System.getProperty("java.version")}")
@@ -1,157 +0,0 @@
package moe.crx.ovrport
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.draganddrop.awtTransferable
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import moe.crx.ovrport.ui.AppContent
import org.jetbrains.skia.Image
import java.io.FileInputStream
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
import moe.crx.ovrport.patch.CantDecodeApkException
import moe.crx.ovrport.patch.CantUpdateOverportException
import moe.crx.ovrport.ui.MainViewModel
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import overportapp.composeapp.generated.resources.*
import overportapp.composeapp.generated.resources.Res
import overportapp.composeapp.generated.resources.apk_file_exported
import overportapp.composeapp.generated.resources.cant_read_apk
import overportapp.composeapp.generated.resources.window_icon
import java.awt.FileDialog
import java.awt.Frame
import java.awt.datatransfer.DataFlavor
import java.io.File
import java.io.FileOutputStream
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "overport",
icon = painterResource(Res.drawable.window_icon),
) {
val viewModel: MainViewModel = viewModel { MainViewModel() }
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
// TODO Deal with this copy-paste
val cantReadApkMessage = stringResource(Res.string.cant_read_apk)
val apkFileExportedMessage = stringResource(Res.string.apk_file_exported)
val cantUpdateOverportMessage = stringResource(Res.string.cant_update_overport)
val unknownErrorMessage = stringResource(Res.string.unknown_error)
suspend fun openFile(file: File) {
if (!file.exists() || file.extension != "apk") {
return
}
val dataDir = File(System.getProperty("user.home")).resolve("overport/cache")
dataDir.mkdirs()
FileInputStream(file).use { s ->
try {
viewModel.import(dataDir, file.name, s)
} catch (_: CantDecodeApkException) {
viewModel.cancel()
snackbarHostState.showSnackbar(cantReadApkMessage)
} catch (_: CantUpdateOverportException) {
viewModel.cancel()
snackbarHostState.showSnackbar(cantUpdateOverportMessage)
} catch (_: Throwable) {
viewModel.cancel()
snackbarHostState.showSnackbar(unknownErrorMessage)
}
}
}
val dragAndDropTarget = remember {
object: DragAndDropTarget {
override fun onDrop(event: DragAndDropEvent): Boolean {
val value: List<File>? = event.awtTransferable
.takeIf { it.isDataFlavorSupported(DataFlavor.javaFileListFlavor) }
?.getTransferData(DataFlavor.javaFileListFlavor)
?.let { it as? List<*> }
?.filterIsInstance<File>()
if (value?.size == 1) {
coroutineScope.launch {
openFile(value.first())
}
return true
}
return false
}
}
}
Box(modifier = Modifier.fillMaxSize().dragAndDropTarget(
shouldStartDragAndDrop = { !viewModel.working && !viewModel.isApkLoaded() },
target = dragAndDropTarget
)) {
AppContent(
viewModel = viewModel.apply {
setAppIconConverter {
FileInputStream(it).use { stream ->
Image.makeFromEncoded(stream.readAllBytes()).toComposeImageBitmap()
}
}
},
snackbarHostState = snackbarHostState,
onOpen = {
val fileDialog = FileDialog(Frame(), "Select a file", FileDialog.LOAD)
fileDialog.isVisible = true
val file = fileDialog.file?.let { File(fileDialog.directory).resolve(it) }
coroutineScope.launch {
if (file != null) {
openFile(file)
}
}
},
onConfirm = {
val fileDialog = FileDialog(Frame(), "Select a file", FileDialog.SAVE)
coroutineScope.run {
fileDialog.file = viewModel.patchedName()
}
fileDialog.isVisible = true
val file = fileDialog.file?.let { File(fileDialog.directory).resolve(it) }
coroutineScope.launch {
if (file != null) {
viewModel.process(it)
FileOutputStream(file).use { s ->
viewModel.export(s)
launch {
snackbarHostState.showSnackbar(apkFileExportedMessage)
}
}
viewModel.cancel()
}
}
},
onCancel = {
coroutineScope.launch {
viewModel.cancel()
}
},
)
}
}
}
+1 -1
View File
@@ -10,4 +10,4 @@ android.nonTransitiveRClass=true
android.useAndroidX=true
#Application
appVersion=1.1.0
appVersion=1.2.0
+18 -15
View File
@@ -1,17 +1,18 @@
[versions]
agp = "8.5.2"
android-compileSdk = "34"
android-minSdk = "24"
android-targetSdk = "34"
androidx-activityCompose = "1.9.3" # Do not update it
androidx-lifecycle = "2.8.4"
compose-multiplatform = "1.7.0"
kotlin = "2.1.0"
kotlinx-coroutines = "1.10.1"
apksig = "8.10.0-alpha07"
bcpkix = "1.80"
kotlinxSerializationJson = "1.8.0"
lifecycleViewmodelCompose = "2.8.2"
agp = "8.12.3"
android-compileSdk = "36"
android-minSdk = "29"
android-targetSdk = "36"
androidx-activityCompose = "1.12.1"
androidx-lifecycle = "2.9.6"
compose-multiplatform = "1.9.3"
kotlin = "2.2.21"
kotlinx-coroutines = "1.10.2"
apksig = "8.12.3"
bcpkix = "1.83"
kotlinx-serialization = "1.9.0"
lifecycle-viewmodel-compose = "2.9.6"
compose-hot-reload = "1.0.0"
[libraries]
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activityCompose" }
@@ -19,11 +20,12 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
androidx-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
androidx-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle-viewmodel-compose" }
android-tools-build = { group = "com.android.tools.build", name = "apksig", version.ref = "apksig" }
bouncycastle-pkix = { group = "org.bouncycastle", name = "bcpkix-jdk18on", version.ref = "bcpkix" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name ="kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name ="kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name ="kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
@@ -32,3 +34,4 @@ composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-mu
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hot-reload" }
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+31
View File
@@ -0,0 +1,31 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
kotlin("jvm")
kotlin("plugin.serialization") version "2.2.21"
application
id("com.gradleup.shadow") version "9.2.2"
}
group = "moe.crx"
version = "1.2.0"
dependencies {
implementation(libs.android.tools.build)
implementation(libs.bouncycastle.pkix)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(fileTree("libs"))
}
tasks.test {
useJUnitPlatform()
}
application {
mainClass = "moe.crx.overport.cli.CliMainKt"
}
tasks.named<ShadowJar>("shadowJar") {
mergeServiceFiles()
}
File renamed without changes.
@@ -0,0 +1,28 @@
package moe.crx.overport.cli
import kotlinx.coroutines.runBlocking
import moe.crx.overport.cli.CommandStore.COMMANDS
import moe.crx.overport.commands.DisplayHelpCommand
abstract class CliCommand {
abstract fun name(): String
abstract fun description(): String
abstract fun printHelp(): Boolean
abstract suspend fun execute(args: List<String>): Boolean
override fun toString() = name()
}
fun main(args: Array<String>) {
val command = args.firstOrNull()?.let { COMMANDS[it] } ?: run {
DisplayHelpCommand.printHelp()
return
}
val successful = runBlocking {
command.execute(args.drop(1))
}
if (!successful) {
command.printHelp()
}
}
@@ -0,0 +1,15 @@
package moe.crx.overport.cli
import moe.crx.overport.commands.DisplayHelpCommand
import moe.crx.overport.commands.ListPatchesCommand
import moe.crx.overport.commands.PatchApplicationCommand
import moe.crx.overport.commands.VersionManagerCommand
object CommandStore {
val COMMANDS = listOf(
ListPatchesCommand,
PatchApplicationCommand,
DisplayHelpCommand,
VersionManagerCommand,
).associateBy { it.name() }
}
@@ -0,0 +1,44 @@
package moe.crx.overport.commands
import moe.crx.overport.cli.CliCommand
import moe.crx.overport.cli.CommandStore.COMMANDS
import moe.crx.overport.utils.PrintUtil.fspace
import moe.crx.overport.utils.PrintUtil.uspace
import moe.crx.overport.versions.VersionManager
object DisplayHelpCommand : CliCommand() {
override fun name() = "help"
override fun description() = "Display this message."
override fun printHelp(): Boolean {
println("overport cli utility, version ${VersionManager.VERSION}")
println("usage: overport <command> [args]")
println()
println("Available commands:")
COMMANDS.values.forEach {
print(fspace())
print(it.name())
print(uspace(it, COMMANDS.values))
println(it.description())
}
println()
println("overport help <command> shows help information for that specific command.")
println()
println("For additional info, see: https://github.com/ovrport/app")
return true
}
override suspend fun execute(args: List<String>): Boolean {
val command = args.firstOrNull()?.let { COMMANDS[it] } ?: run {
DisplayHelpCommand.printHelp()
return true
}
val hasHelp = command.printHelp()
if (!hasHelp) {
printHelp()
}
return true
}
}
@@ -0,0 +1,24 @@
package moe.crx.overport.commands
import moe.crx.overport.cli.CliCommand
import moe.crx.overport.patching.PatchStore
object ListPatchesCommand : CliCommand() {
override fun name() = "patches"
override fun description() = "Print list of available patches."
override fun printHelp(): Boolean {
return false
}
override suspend fun execute(args: List<String>): Boolean {
println("Available patches:")
PatchStore.all().forEach {
print("- ")
println(it.name)
}
return true
}
}
@@ -0,0 +1,194 @@
package moe.crx.overport.commands
import kotlinx.coroutines.cancelAndJoin
import moe.crx.overport.cli.CliCommand
import moe.crx.overport.patches.PLATFORM_ICON_RESIZER
import moe.crx.overport.patching.PatchStore
import moe.crx.overport.patching.PatcherContext
import moe.crx.overport.utils.DesktopUtil.defaultWorkspace
import moe.crx.overport.utils.ImageIOIconResizer
import moe.crx.overport.utils.NameFormatter.DEFAULT_FORMAT
import moe.crx.overport.utils.PrintUtil.drawProgressBar
import moe.crx.overport.utils.PrintUtil.drawSpinner
import moe.crx.overport.utils.PrintUtil.updateLine
import moe.crx.overport.versions.VersionManager
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
object PatchApplicationCommand : CliCommand() {
override fun name() = "patch"
override fun description() = "Patch provided APK file."
override fun printHelp(): Boolean {
println("usage: overport patch --input=<path> [--output=<path>]")
println(" [--output-name=<format>] [--workspace=<path>] [--patches=<names>] [--version=<value>]")
println()
println("--input=<path>")
println("Specifies input APK file to patch.")
println()
println("--output=<path>")
println("Specifies output APK file directory path.")
println("If not specified, uses same path as input file.")
println("If specified path is a file, uses it's parent directory.")
println()
println("--output-name=<format>")
println("Specifies output APK file name format string.")
println("If not specified, uses name of input file but with \"output\" suffix appended to it.")
println("See more information about name format below.")
println()
println("--workspace=<path>")
println("Specifies workspace directory path.")
println("If not specified, uses default one in user's home directory.")
println()
println("--patches=<value>")
println("Specifies patches to apply. Value should be a semicolon-separated list of patch names.")
println("If not specified, uses every recommended patch available. Use \"overport patches\" to see available patches.")
println("You can also specify arguments for a specific patch using <patch name>=<comma-separated arguments>.")
println()
println("--version=<value>")
println("Specifies which overport version to use for patching.")
println("Value also could be \"experimental\" or \"latest\".")
println("If not specified, uses latest one. Use \"overport install\" to see available versions.")
println()
println("About file name format:")
println("File name format allows to specify pattern for automatic file name generation.")
println("Default file name format is $DEFAULT_FORMAT.")
println("Available format tags:")
println("{package} - application package name")
println("{label} - application label")
println("{code} - application version code")
println("{version} - application version name")
println("{overport} - overport version used for patching")
println("{filename} - input file name without extension")
println("{ext} - input file extension")
println("{filenameext} - input file name with extension")
return true
}
private data class ParsedArguments(
var inputFile: String? = null,
var outputFile: String? = null,
var workspace: String? = null,
var patches: Map<String, List<String>>? = null,
var nameFormat: String? = null,
var overportVersion: String? = null,
)
private fun scanArguments(args: List<String>): ParsedArguments {
val parsed = ParsedArguments()
val associated = args.associate {
it.substringBefore('=') to it.substringAfter('=', "")
}
associated.forEach { (key, value) ->
when (key) {
"--input" -> parsed.inputFile = value
"--output" -> parsed.outputFile = value
"--workspace" -> parsed.workspace = value
"--patches" -> parsed.patches = value.split(';').map { splitted -> splitted.trim() }.associate {
it.substringBefore('=') to it.substringAfter('=', "").split(',').map { splitted -> splitted.trim() }
}
"--output-name" -> parsed.nameFormat = value
"--version" -> parsed.overportVersion = value
}
}
return parsed
}
override suspend fun execute(args: List<String>): Boolean {
val parsed = scanArguments(args)
val inputFile = parsed.inputFile?.let { File(it) } ?: return false
if (!inputFile.isFile) {
return false
}
var outputDir = parsed.outputFile?.let { File(it) } ?: inputFile.parentFile
if (outputDir.isFile) {
outputDir = outputDir.parentFile
}
val workspace = parsed.workspace?.let { File(it) } ?: defaultWorkspace()
val patches = parsed.patches ?: PatchStore.recommended().associate { it.name to listOf() }
val nameFormat = parsed.nameFormat ?: DEFAULT_FORMAT
val version = parsed.overportVersion ?: "latest"
println("overport cli utility, version ${VersionManager.VERSION}")
println("Input APK: ${inputFile.absolutePath}")
println("Output directory: ${outputDir.absolutePath}")
println("Workspace directory: ${workspace.absolutePath}")
PLATFORM_ICON_RESIZER = ImageIOIconResizer
val patcher = PatcherContext(version, workspace, inputFile.name, nameFormat)
val waitingForLibraries = updateLine {
drawSpinner()
print("Preparing libraries...")
}
patcher.checkout()
waitingForLibraries.cancelAndJoin()
println("Using overport version: ${patcher.overportVersion()}")
val waitingForDecompilation = updateLine {
drawSpinner()
print("Decompiling APK...")
}
FileInputStream(inputFile).use { fis ->
patcher.prepare(fis)
}
waitingForDecompilation.cancelAndJoin()
println("Decompiled app: ${patcher.appName()}, package ${patcher.appPackage()}, version ${patcher.appVersionName()}")
val totalPatches = patches.size
var appliedPatches = 0
var currentPatch = ""
val waitingForPatching = updateLine {
drawSpinner()
drawProgressBar(appliedPatches.toLong(), totalPatches.toLong())
print("Applying patch $currentPatch... ")
print("($appliedPatches / $totalPatches)")
}
patcher.patch(patches) { index, patch ->
appliedPatches = index
currentPatch = patch.name
}
waitingForPatching.cancelAndJoin()
outputDir.mkdirs()
val outputFile = File(outputDir, patcher.outputFileName())
println("Output file: ${outputFile.absolutePath}")
val waitingForCompilation = updateLine {
drawSpinner()
print("Compiling APK... ")
}
FileOutputStream(outputFile).use { fos ->
patcher.export(fos)
}
waitingForCompilation.cancelAndJoin()
val waitingForCleaning = updateLine {
drawSpinner()
print("Cleaning up... ")
}
patcher.cleanup()
waitingForCleaning.cancelAndJoin()
println("Patching successful.")
return true
}
}
@@ -0,0 +1,152 @@
package moe.crx.overport.commands
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import moe.crx.overport.cli.CliCommand
import moe.crx.overport.utils.DesktopUtil.defaultWorkspace
import moe.crx.overport.utils.PrintUtil.drawSpinner
import moe.crx.overport.utils.PrintUtil.updateLine
import moe.crx.overport.versions.OverportRelease
import moe.crx.overport.versions.VersionManager
import moe.crx.overport.versions.VersionManager.Companion.isIncompatible
import java.io.File
object VersionManagerCommand : CliCommand() {
val scope = CoroutineScope(Dispatchers.IO)
override fun name() = "install"
override fun description() = "Manage overport instances."
override fun printHelp(): Boolean {
println("usage: overport install [--workspace=<path>] [--version=<value>]")
println()
println("--workspace=<path>")
println("Specifies workspace directory path.")
println("If not specified, uses default one in user's home directory.")
println()
println("--version=<value>")
println("Specifies which overport version to install or remove.")
println("Value also could be \"experimental\" or \"latest\".")
println("If not specified, prints all available or installed versions.")
println()
println("--remove")
println("Specifies if the version should be removed.")
println("If not specified, manager installs this version.")
return true
}
private data class ParsedArguments(
var workspace: String? = null,
var overportVersion: String? = null,
var remove: Boolean = false,
)
private fun scanArguments(args: List<String>): ParsedArguments {
val parsed = ParsedArguments()
val associated = args.associate {
it.substringBefore('=') to it.substringAfter('=', "")
}
associated.forEach { (key, value) ->
when (key) {
"--workspace" -> parsed.workspace = value
"--version" -> parsed.overportVersion = value
"--remove" -> parsed.remove = true
}
}
return parsed
}
fun printVersionList(listed: List<OverportRelease>, installed: List<OverportRelease>) {
listed.forEachIndexed { index, rel ->
val isInstalled = installed.firstOrNull { it.version == rel.version } != null
print("[")
if (!isInstalled) {
print(" ")
} else {
print("x")
}
print("] ")
print(rel.version)
if (index == 0) {
print(", latest")
}
if (rel.isExperimental) {
print(", experimental")
}
if (rel.isCustom) {
print(", custom")
}
if (isInstalled) {
print(" [installed]")
}
if (isIncompatible(rel)) {
print(" [incompatible]")
}
println()
}
}
suspend fun printVersionsInfo(workspace: File) {
val manager = VersionManager(workspace)
val waiting = updateLine {
drawSpinner()
print("Waiting for versions index...")
}
val available = scope.async { manager.available() }.await()
val installed = manager.installed()
waiting.cancelAndJoin()
if (available.isEmpty()) {
println("overport release index is not available. Check your internet connection.")
if (installed.isEmpty()) {
println("You have no versions installed so you can't use patching yet.")
} else {
println("You have some versions installed that are available offline:")
printVersionList(installed, installed)
}
return
}
println("Available versions:")
printVersionList(available, installed)
val noMoreAvailable = installed.filter { rel -> available.firstOrNull { rel.version == it.version } == null }
printVersionList(noMoreAvailable, installed)
}
override suspend fun execute(args: List<String>): Boolean {
val parsed = scanArguments(args)
val workspace = parsed.workspace?.let { File(it) }
?: defaultWorkspace()
val version = parsed.overportVersion
if (version == null) {
printVersionsInfo(workspace)
} else {
if (parsed.remove) {
VersionManager(workspace).uninstall(version)
} else {
VersionManager(workspace).checkout(version)
}
}
return true
}
}
@@ -0,0 +1,38 @@
package moe.crx.overport.config
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import moe.crx.overport.utils.HttpUtil
@Serializable
data class ApplicationMetaImage(
@SerialName("file_name")
var fileName: String,
@SerialName("id")
var id: String,
@SerialName("image_type")
var imageType: String,
@SerialName("uri")
var uri: String,
)
@Serializable
data class ApplicationMetaInfo(
@SerialName("metaID")
var metaId: String,
@SerialName("displayName")
var displayName: String,
@SerialName("images")
var images: List<ApplicationMetaImage>,
)
private val json = Json { ignoreUnknownKeys = true }
fun getApplicationInfo(pkg: String): ApplicationMetaInfo? {
val url = "https://ovrp.crx.moe/images/by_package?package=$pkg"
return runCatching {
val string = String(HttpUtil.download(url, timeout = 20000))
json.decodeFromString<ApplicationMetaInfo>(string)
}.getOrNull()
}
@@ -0,0 +1,17 @@
package moe.crx.overport.config
import kotlinx.serialization.json.Json
private val json = Json { ignoreUnknownKeys = true }
fun OverportConfig.updateDatabase(fridaString: String) {
val config = runCatching {
json.decodeFromString<FridaConfig>(fridaString)
}.getOrNull() ?: return
val products = config.interaction.parameters.skus
val assets = config.interaction.parameters.assets.map { OverportConfigAsset(it.id, it.path, it.type) }
val application = OverportConfigApplication("__current__", products, assets)
database = listOf(application)
}
@@ -0,0 +1,28 @@
package moe.crx.overport.config
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class FridaAsset(
@SerialName("ID")
val id: Long = 0,
val path: String = "",
val type: String = "store"
)
@Serializable
data class FridaParameters(
val assets: List<FridaAsset> = listOf(),
val skus: List<String> = listOf()
)
@Serializable
data class FridaInteraction(
val parameters: FridaParameters = FridaParameters(),
)
@Serializable
data class FridaConfig(
val interaction: FridaInteraction = FridaInteraction(),
)
@@ -1,7 +1,5 @@
package moe.crx.ovrport.model
package moe.crx.overport.config
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -22,7 +20,7 @@ data class OverportConfigAsset(
@SerialName("file_path")
var filePath: String,
@SerialName("asset_type")
var assetType: String = "store",
var assetType: String = "default",
@SerialName("language")
var language: OverportConfigLanguage = OverportConfigLanguage(),
@SerialName("download_status")
@@ -43,12 +41,28 @@ data class OverportConfigApplication(
var assets: List<OverportConfigAsset>,
)
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class OverportConfigV1(
data class OverportPatchedInfo(
@SerialName("by")
var by: String,
@SerialName("with")
var with: String,
@SerialName("patches")
var patches: List<String>,
)
@Serializable
data class OverportConfig(
@SerialName("version")
@EncodeDefault
var version: Int = 1,
var version: Int = 0,
@SerialName("disable_controller_offset")
var disableControllerOffset: Int = 0,
@SerialName("force_passthrough")
var forcePassthrough: Int = 0,
@SerialName("disable_space_warp")
var disableSpaceWarp: Int = 0,
@SerialName("verbose_logging")
var verboseLogging: Int = 0,
@SerialName("suggested_texture_width")
var suggestedTextureWidth: Int = 0,
@SerialName("suggested_texture_height")
@@ -62,5 +76,7 @@ data class OverportConfigV1(
@SerialName("vibration_frequency_multiplier")
var vibrationFrequencyMultiplier: Double = 1.0,
@SerialName("database")
var database: List<OverportConfigApplication>,
var database: List<OverportConfigApplication> = listOf(),
@SerialName("patched")
var patched: OverportPatchedInfo? = null,
)
@@ -0,0 +1,37 @@
package moe.crx.overport.patches
import moe.crx.overport.patching.Patch
import moe.crx.overport.utils.nameAttribute
import moe.crx.overport.utils.named
import moe.crx.overport.utils.takeNodesEach
val PATCH_CLEAN_UP_FRIDA = Patch("patch_clean_up_frida") {
selectLibrary("libfrda.so") {
file.delete()
}
selectLibrary("libfrda.config.so") {
file.delete()
}
selectLibrary("libscript.so") {
file.delete()
}
selectManifestJson {
takeNodesEach({ named("manifest") }) {
takeNodesEach({ named("application") }) {
takeNodesEach({ named("activity") }) {
nameAttribute()?.let {
selectSmali(it.replace('.', '/')) {
replace(
"const-string (\\w+), \\\"frda\\\"\\s+?invoke-static \\{\\1\\}, Ljava/lang/System;->loadLibrary\\(Ljava/lang/String;\\)V",
""
)
}
}
this
}
}
}
}
}
@@ -0,0 +1,18 @@
package moe.crx.overport.patches
import moe.crx.overport.patching.Patch
import java.io.File
val PATCH_COPY_LIBRARIES = Patch("patch_copy_libraries") {
selectWorkspace {
file.resolve("root/lib").listFiles()?.forEach { archDirectory ->
val arch = getLibraries().resolve("lib/${archDirectory.name}")
if (arch.exists()) {
arch.listFiles().filter { it.name != "libOVRPlugin.so" }.forEach { library ->
library.copyTo(File(archDirectory, library.name), true)
}
}
}
}
}
@@ -0,0 +1,21 @@
package moe.crx.overport.patches
import moe.crx.overport.patching.Patch
import java.io.File
val PATCH_COPY_OVRPLUGIN_VRAPI = Patch("patch_copy_ovrplugin_vrapi") {
selectWorkspace {
file.resolve("root/lib").listFiles()?.forEach { archDirectory ->
val arch = getLibraries().resolve("lib/${archDirectory.name}")
if (arch.exists()) {
val hasVrApi = arch.listFiles().find { it.name.lowercase() == "libvrapi.so" } != null
if (hasVrApi) {
arch.listFiles().filter { it.name == "libOVRPlugin.so" }.forEach { library ->
library.copyTo(File(archDirectory, library.name), true)
}
}
}
}
}
}
@@ -0,0 +1,9 @@
package moe.crx.overport.patches
import moe.crx.overport.patching.Patch
val PATCH_DISABLE_CONTROLLER_OFFSET = Patch("patch_disable_controller_offset", false) {
selectConfig {
disableControllerOffset = 1
}
}
@@ -0,0 +1,9 @@
package moe.crx.overport.patches
import moe.crx.overport.patching.Patch
val PATCH_DISABLE_SPACE_WARP = Patch("patch_disable_space_warp", false) {
selectConfig {
disableSpaceWarp = 1
}
}
@@ -0,0 +1,59 @@
package moe.crx.overport.patches
import moe.crx.overport.patching.Patch
import moe.crx.overport.utils.named
import moe.crx.overport.utils.take
import moe.crx.overport.utils.takeAttributesEach
import moe.crx.overport.utils.takeNodesEach
val PATCH_FIX_MIN_ANDROID_SDK = Patch("patch_fix_min_android_sdk") {
selectManifestJson {
takeNodesEach({ named("manifest") }) {
takeNodesEach({ named("uses-sdk") }) {
takeAttributesEach({ named("minSdkVersion") }) {
take<Int>("data") { 29 }
}
}
}
}
selectSmali {
replace(
"invoke-virtual \\{(\\w+), (\\w+)\\}, Landroid/view/Window;->setDecorFitsSystemWindows\\(Z\\)V", ""
)
}
selectSmali("androidx/core/view/WindowCompat\$Api30Impl") {
replace(
"invoke-virtual \\{(\\w+), (\\w+)\\}, Landroid/view/Window;->setDecorFitsSystemWindows\\(Z\\)V", ""
)
}
selectSmali("androidx/core/view/WindowInsetsCompat\$TypeImpl30") {
replace(
"(toPlatformType\\(I\\)I.*?)\\.locals (\\w+)", "$0\nconst v0, 0x0\nreturn v0"
)
}
selectSmali("androidx/core/view/WindowInsetsCompat\$Impl30") {
replace(
"(getInsets\\(I\\)Landroidx/core/graphics/Insets;.*?)\\.locals (\\w+)",
"$0\nsget-object p0, Landroidx/core/graphics/Insets;->NONE:Landroidx/core/graphics/Insets;\nreturn-object p0"
)
replace("(isVisible\\(I\\)Z.*?)\\.locals (\\w+)", "$0\nconst/4 p0, 0\nreturn p0")
}
selectSmali("com/epicgames/unreal/GameActivity") {
replace(
"(if-lt \\w+?, \\w+?, :\\S+\\s*?)?const-string \\w+?, \\\"vibrator_manager\\\".*?iput-object \\w+?, \\w+?, Lcom/epicgames/unreal/GameActivity;->\\w+?:Landroid/os/Vibrator;",
""
)
}
selectSmali("com/unity3d/player/PlatformSupport") {
replace("(\\.field static final RED_VELVET_CAKE_SUPPORT:Z)( = true)?( = false)?", "$1 = false")
replace("(\\.field static final SNOW_CONE_SUPPORT:Z)( = true)?( = false)?", "$1 = false")
replace("sput-boolean v1, Lcom\\/unity3d\\/player\\/PlatformSupport;->RED_VELVET_CAKE_SUPPORT:Z", "")
replace("sput-boolean v1, Lcom\\/unity3d\\/player\\/PlatformSupport;->SNOW_CONE_SUPPORT:Z", "")
}
}
@@ -0,0 +1,20 @@
package moe.crx.overport.patches
import moe.crx.overport.patching.Patch
val PATCH_FIX_UNREAL_CRASH = Patch("patch_fix_unreal_crash") {
createSmali("com/unity3d/player/UnityPlayer") {
if (readText().isBlank()) {
append(".class public Lcom/unity3d/player/UnityPlayer;")
append(".super Ljava/lang/Object;")
append(".field public static currentActivity:Landroid/app/Activity;")
}
}
selectSmali("com/epicgames/ue4/GameActivity") {
replace(
"sput-object (\\w+), Lcom\\/epicgames\\/ue4\\/GameActivity;->\\w+:Lcom\\/epicgames\\/ue4\\/GameActivity;",
"$0\nsput-object $1, Lcom/unity3d/player/UnityPlayer;->currentActivity:Landroid/app/Activity;"
)
}
}
@@ -0,0 +1,9 @@
package moe.crx.overport.patches
import moe.crx.overport.patching.Patch
val PATCH_FORCE_PASSTHROUGH = Patch("patch_force_passthrough", false) {
selectConfig {
forcePassthrough = 1
}
}
@@ -0,0 +1,14 @@
package moe.crx.overport.patches
import moe.crx.overport.config.updateDatabase
import moe.crx.overport.patching.Patch
val PATCH_GENERATE_CONFIG = Patch("patch_generate_config") {
selectLibrary("libfrda.config.so") {
readText().let { fridaJson ->
selectConfig {
updateDatabase(fridaJson)
}
}
}
}
@@ -0,0 +1,55 @@
package moe.crx.overport.patches
import com.reandroid.json.JSONArray
import com.reandroid.json.JSONObject
import moe.crx.overport.patching.Patch
import moe.crx.overport.utils.*
fun createAction(value: String): JSONObject {
return JSONObject().put("node_type", "element").put("name", "action").put(
"attributes", JSONArray().put(
JSONObject().put("name", "name").put("id", 16842755)
.put("uri", "http://schemas.android.com/apk/res/android").put("prefix", "android")
.put("value_type", "STRING").put("data", value)
)
)
}
fun createCategory(value: String): JSONObject {
return JSONObject().put("node_type", "element").put("name", "category").put(
"attributes", JSONArray().put(
JSONObject().put("name", "name").put("id", 16842755)
.put("uri", "http://schemas.android.com/apk/res/android").put("prefix", "android")
.put("value_type", "STRING").put("data", value)
)
)
}
val PATCH_LAUNCHER_ENTRY = Patch("patch_launcher_entry") {
selectManifestJson {
takeNodesEach({ named("manifest") }) {
takeNodesEach({ named("application") }) {
takeNodesEach({ named("activity") }) {
takeNodesEach({ named("intent-filter") }) {
val hasIntent = elem<JSONArray>("nodes").elemEach<JSONObject> { named("category") }.map {
when (it.nameAttribute()) {
"com.oculus.intent.category.VR" -> true
"android.intent.category.LAUNCHER" -> true
else -> false
}
}.reduceOrNull { l, r -> l || r } ?: false
takeNodes {
if (!hasIntent) this else {
JSONArray().put(createAction("android.intent.action.MAIN"))
.put(createCategory("android.intent.category.LAUNCHER"))
.put(createCategory("com.oculus.intent.category.VR"))
.put(createCategory("com.yvr.intent.category.VR"))
}
}
}
}
}
}
}
}
@@ -0,0 +1,27 @@
package moe.crx.overport.patches
import com.reandroid.json.JSONObject
import moe.crx.overport.patching.Patch
import moe.crx.overport.utils.*
fun createAllowBackup(): JSONObject {
return JSONObject().put("name", "allowBackup").put("id", 16843392)
.put("uri", "http://schemas.android.com/apk/res/android").put("prefix", "android").put("value_type", "BOOLEAN")
.put("data", true)
}
val PATCH_MARK_ALLOW_BACKUP = Patch("patch_mark_allow_backup") {
selectManifestJson {
takeNodesEach({ named("manifest") }) {
takeNodesEach({ named("application") }) {
takeAttributes {
if (elemEach<JSONObject> { named("allowBackup") }.isEmpty()) {
this?.put(createAllowBackup())
}
this
}
}
}
}
}
@@ -0,0 +1,27 @@
package moe.crx.overport.patches
import com.reandroid.json.JSONObject
import moe.crx.overport.patching.Patch
import moe.crx.overport.utils.*
fun createDebuggable(): JSONObject {
return JSONObject().put("name", "debuggable").put("id", 16842767)
.put("uri", "http://schemas.android.com/apk/res/android").put("prefix", "android").put("value_type", "BOOLEAN")
.put("data", true)
}
val PATCH_MARK_AS_DEBUGGABLE = Patch("patch_mark_as_debuggable") {
selectManifestJson {
takeNodesEach({ named("manifest") }) {
takeNodesEach({ named("application") }) {
takeAttributes {
if (elemEach<JSONObject> { named("debuggable") }.isEmpty()) {
this?.put(createDebuggable())
}
this
}
}
}
}
}
@@ -0,0 +1,27 @@
package moe.crx.overport.patches
import moe.crx.overport.patching.Patch
val PATCH_META_XR_AUDIO = Patch("patch_meta_xr_audio") {
selectLibrary("libMetaXRAudioWwise.so") {
replaceHex(
"57 D0 3B D5 40 EB FF F0 00 0C 2D 91 E8 16 40 F9 A8 83 1F F8 DC 7F 0B 94 00 01 00 B4 E3 03 00 AA 21 EB FF B0",
"57 D0 3B D5 20 00 80 D2 1F 20 03 D5 E8 16 40 F9 A8 83 1F F8 1F 20 03 D5 00 01 00 B4 03 00 80 D2 21 EB FF B0"
)
}
// TODO make a more universal patch?
selectLibrary("libMetaXRAudioWwise.so") {
replaceHex(
"21 48 26 91 E0 03 14 AA 08 19 40 F9 00 01 3F D6 A0 07 00 B4 88 02 40 F9",
"21 48 26 91 E0 03 14 AA E0 03 1F AA 1F 20 03 D5 A0 07 00 B4 88 02 40 F9"
)
}
selectLibrary("libMetaXRAudioUnity.so") {
replaceHex(
"E0 03 14 AA 08 19 40 F9 00 01 3F D6 A0 07 00 B4 88 02 40 F9 E1 03 00 AA",
"E0 03 14 AA 08 19 40 F9 E0 03 1F AA 44 00 00 14 88 02 40 F9 E1 03 00 AA"
)
}
}
@@ -0,0 +1,11 @@
package moe.crx.overport.patches
import moe.crx.overport.patching.Patch
val PATCH_OCULUS_UNITY = Patch("patch_oculus_unity") {
selectSmali("com/unity/oculus/OculusUnity") {
replace(
"(getIsOnOculusHardware\\(\\)Z.*?)return (\\w+)", "$1\nconst/4 $2, 0x1\nreturn $2"
)
}
}
@@ -0,0 +1,17 @@
package moe.crx.overport.patches
import moe.crx.overport.patching.Patch
val PATCH_OCULUS_UNREAL = Patch("patch_oculus_unreal") {
selectSmali(
"com/epicgames/ue4/GameActivity",
"com/epicgames/unreal/GameActivity",
) {
replace(
"sget-object (\\w+), Landroid/os/Build;->MANUFACTURER:Ljava/lang/String;", "const-string $1, \"Oculus\""
)
replace(
"sget-object (\\w+), Landroid/os/Build;->MODEL:Ljava/lang/String;", "const-string $1, \"Quest 2\""
)
}
}
@@ -0,0 +1,34 @@
package moe.crx.overport.patches
import com.reandroid.json.JSONArray
import com.reandroid.json.JSONObject
import moe.crx.overport.patching.Patch
import moe.crx.overport.utils.elem
import moe.crx.overport.utils.take
import moe.crx.overport.utils.takeEach
val PATCH_REMOVE_LOCALIZED_NAMES = Patch("patch_remove_localized_names") {
selectResourcesJson {
take<JSONArray>("packages") {
takeEach<JSONObject> {
take<JSONArray>("specs") {
takeEach<JSONObject> {
take<JSONArray>("types") {
takeEach<JSONObject> {
val configLanguage = elem<JSONObject>("config").elem<String>("language")
take<JSONArray>("entries") {
takeEach<JSONObject> {
this.takeIf {
elem<String>("entry_name") != "app_name" || configLanguage == null
}
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,15 @@
package moe.crx.overport.patches
import moe.crx.overport.patching.Patch
val PATCH_REMOVE_UNREAL_FORCE_QUIT = Patch("patch_remove_unreal_force_quit", false) {
selectSmali(
"com/epicgames/ue4/GameActivity",
"com/epicgames/unreal/GameActivity",
) {
replace(
"(\\.method public AndroidThunkJava_ForceQuit\\(\\)V.*?)(invoke-static \\{\\w\\d+?\\}, Ljava\\/lang\\/System;->exit\\(I\\)V)(.*?\\.end method)",
"$1$3"
)
}
}
@@ -0,0 +1,16 @@
package moe.crx.overport.patches
import moe.crx.overport.patching.Patch
import moe.crx.overport.utils.named
import moe.crx.overport.utils.takeNodesEach
val PATCH_REMOVE_USES_LIBRARY = Patch("patch_remove_uses_library") {
selectManifestJson {
takeNodesEach({ named("manifest") }) {
takeNodesEach({ named("application") }) {
takeNodesEach({ named("uses-library") }) { null }
takeNodesEach({ named("uses-native-library") }) { null }
}
}
}
}
@@ -0,0 +1,118 @@
package moe.crx.overport.patches
import com.reandroid.json.JSONObject
import moe.crx.overport.config.getApplicationInfo
import moe.crx.overport.patching.Patch
import moe.crx.overport.utils.named
import moe.crx.overport.utils.takeAttributes
import moe.crx.overport.utils.takeAttributesEach
import moe.crx.overport.utils.takeNodesEach
import java.util.*
interface IconResizer {
fun resize(bytes: ByteArray, width: Int, height: Int): ByteArray
}
fun createLabel(label: String): JSONObject {
return JSONObject().put("name", "label").put("id", 16842753)
.put("uri", "http://schemas.android.com/apk/res/android").put("prefix", "android").put("value_type", "STRING")
.put("data", label)
}
fun createIconReference(reference: Int): JSONObject {
return JSONObject().put("name", "icon").put("id", 16842754)
.put("uri", "http://schemas.android.com/apk/res/android").put("prefix", "android")
.put("value_type", "REFERENCE")
.put("data", reference)
}
var PLATFORM_ICON_RESIZER: IconResizer? = null
val PATCH_REPLACE_ICON_LABEL = Patch("patch_replace_icon_label") { arguments ->
var reference = 0
var iconDownloaded = false
var gameTitle: String? = null
createFile("root/res/drawable/custom_icon_ovrp.png") {
val info = getApplicationInfo(applicationPackage())
gameTitle = info?.displayName
val image = info?.run {
val associated = images.associateBy { it.imageType }
if (arguments.firstOrNull() == "cover") {
associated["APP_IMG_COVER_SQUARE"] ?: associated["APP_IMG_ICON"]
} else {
associated["APP_IMG_ICON"] ?: associated["APP_IMG_COVER_SQUARE"]
}
}
val bytes = image?.uri?.let { Base64.getDecoder().decode(it) }
var resizedBytes = bytes?.let { PLATFORM_ICON_RESIZER?.resize(it, 512, 512) } ?: bytes
if (resizedBytes != null) {
iconDownloaded = runCatching { writeBytes(resizedBytes) }.isSuccess
}
}
if (iconDownloaded) {
selectPackageBlock {
getOrCreate("", "drawable", "custom_icon_ovrp").apply {
valueAsString = "res/drawable/custom_icon_ovrp.png"
reference = resourceId
}
}
selectManifestJson {
takeNodesEach({ named("manifest") }) {
takeNodesEach({ named("application") }) {
takeNodesEach({ named("activity") }) {
takeAttributesEach({ named("icon") }) {
null
}
}
takeNodesEach({ named("activity-alias") }) {
takeAttributesEach({ named("icon") }) {
null
}
}
takeAttributesEach({ named("icon") }) {
null
}
takeAttributes {
this?.put(createIconReference(reference))
}
}
}
}
}
gameTitle?.let { gameTitle ->
selectManifestJson {
takeNodesEach({ named("manifest") }) {
takeNodesEach({ named("application") }) {
takeNodesEach({ named("activity") }) {
takeAttributesEach({ named("label") }) {
null
}
}
takeNodesEach({ named("activity-alias") }) {
takeAttributesEach({ named("label") }) {
null
}
}
takeAttributesEach({ named("label") }) {
null
}
takeAttributes {
this?.put(createLabel(gameTitle))
}
}
}
}
}
}
@@ -0,0 +1,40 @@
package moe.crx.overport.patches
import com.reandroid.json.JSONArray
import com.reandroid.json.JSONObject
import moe.crx.overport.patching.Patch
import moe.crx.overport.utils.nameAttribute
import moe.crx.overport.utils.named
import moe.crx.overport.utils.takeNodes
import moe.crx.overport.utils.takeNodesEach
fun createMetadata(name: String, value: String): JSONObject {
return JSONObject().put("node_type", "element").put("name", "meta-data").put(
"attributes", JSONArray().put(
JSONObject().put("name", "name").put("id", 16842755)
.put("uri", "http://schemas.android.com/apk/res/android").put("prefix", "android")
.put("value_type", "STRING").put("data", name)
).put(
JSONObject().put("name", "value").put("id", 16842788)
.put("uri", "http://schemas.android.com/apk/res/android").put("prefix", "android")
.put("value_type", "STRING").put("data", value)
)
)
}
val PATCH_VR_METADATA = Patch("patch_vr_metadata") {
selectManifestJson {
takeNodesEach({ named("manifest") }) {
takeNodesEach({ named("application") }) {
takeNodesEach({ named("meta-data") }) {
if (nameAttribute() == "com.oculus.supportedDevices") null else this
}
takeNodes {
this?.put(createMetadata("pvr.app.type", "vr"))
?.put(createMetadata("com.yvr.intent.category.VR", "vr_only"))
?.put(createMetadata("com.oculus.supportedDevices", "all"))
}
}
}
}
}
@@ -0,0 +1,63 @@
package moe.crx.overport.patching
import com.reandroid.json.JSONObject
import java.io.File
class FileTransformer(val file: File) {
fun replace(input: String, output: String) {
file.writeText(Regex(input, RegexOption.DOT_MATCHES_ALL).replace(file.readText(), output))
}
fun readText(): String {
return file.readText()
}
fun append(line: String) {
file.appendText(line)
}
fun readJson(): JSONObject? {
return runCatching { JSONObject(file.readText()) }.getOrNull()
}
fun writeText(text: String) {
file.writeText(text)
}
fun writeBytes(bytes: ByteArray) {
file.writeBytes(bytes)
}
fun writeJson(json: JSONObject) {
writeText(json.toString())
}
fun replaceHex(input: String, output: String) {
val bytes = file.readBytes()
val inputBytes = input.split(' ')
for (i in 0..<bytes.size - inputBytes.size + 1) {
var matches = true
for (j in i..<bytes.size) {
val checkIndex = j - i
if (checkIndex >= inputBytes.size) {
break
}
if (inputBytes[checkIndex] != "??" && bytes[j].toUByte() != inputBytes[checkIndex].toUByte(16)) {
matches = false
break
}
}
if (matches) {
val replaced = bytes.slice(0..<i) +
output.split(' ').map { it.toUByte(16).toByte() } +
bytes.slice(i + inputBytes.size..<bytes.size)
file.writeBytes(replaced.toByteArray())
return
}
}
}
}
@@ -0,0 +1,7 @@
package moe.crx.overport.patching
data class Patch(
val name: String,
val isRecommended: Boolean = true,
val executor: PatchExecutor.(List<String>) -> Unit,
)
@@ -1,66 +1,26 @@
package moe.crx.ovrport.patch
package moe.crx.overport.patching
import com.reandroid.apk.ApkModule
import com.reandroid.apk.ApkModuleJsonDecoder
import com.reandroid.arsc.chunk.PackageBlock
import com.reandroid.json.JSONObject
import kotlinx.serialization.json.Json
import moe.crx.overport.config.OverportConfig
import java.io.File
data class Patch(
val name: String = "",
val desc: String? = null,
val executor: PatchExecutor.() -> Unit
)
class FileTransformer(val file: File) {
fun replace(input: String, output: String) {
file.writeText(Regex(input, RegexOption.DOT_MATCHES_ALL).replace(file.readText(), output))
class PatchExecutor(private val librariesDir: File, private val workingDir: File, private val apkModule: ApkModule) {
companion object {
private val json = Json { ignoreUnknownKeys = true }
}
fun readText(): String {
return file.readText()
fun getLibraries(): File {
return librariesDir
}
fun append(line: String) {
file.appendText(line)
fun applicationPackage(): String {
return apkModule.androidManifest.packageName
}
fun readJson(): JSONObject? {
return runCatching { JSONObject(file.readText()) }.getOrNull()
}
fun writeJson(json: JSONObject) {
file.writeText(json.toString())
}
fun replaceHex(input: String, output: String) {
val bytes = file.readBytes()
val inputBytes = input.split(' ')
for (i in 0..<bytes.size-inputBytes.size+1) {
var matches = true
for (j in i..<bytes.size) {
val checkIndex = j - i
if (checkIndex >= inputBytes.size) {
break
}
if (inputBytes[checkIndex] != "??" && bytes[j].toUByte() != inputBytes[checkIndex].toUByte(16)) {
matches = false
break
}
}
if (matches) {
val replaced = bytes.slice(0..<i) +
output.split(' ').map { it.toUByte(16).toByte() } +
bytes.slice(i+inputBytes.size..<bytes.size)
file.writeBytes(replaced.toByteArray())
return
}
}
}
}
class PatchExecutor(private val workingDir: File) {
fun selectWorkspace(block: FileTransformer.() -> Unit) {
workingDir.run {
if (exists()) {
@@ -77,6 +37,14 @@ class PatchExecutor(private val workingDir: File) {
}
}
fun createFile(fileName: String, block: FileTransformer.() -> Unit) {
workingDir.resolve(fileName).run {
parentFile?.mkdirs()
createNewFile()
FileTransformer(this).block()
}
}
fun selectResources(block: FileTransformer.() -> Unit) {
selectFile("resources/resources.arsc.json", block)
}
@@ -85,6 +53,44 @@ class PatchExecutor(private val workingDir: File) {
selectFile("AndroidManifest.xml.json", block)
}
fun selectResourcesJson(block: JSONObject?.() -> Unit) {
selectResources {
val json = readJson()
block(json)
json?.let { writeJson(it) }
}
}
fun selectManifestJson(block: JSONObject?.() -> Unit) {
selectManifest {
val json = readJson()
block(json)
json?.let { writeJson(it) }
}
}
fun selectPackageBlock(block: PackageBlock.() -> Unit) {
apkModule.tableBlock.getPackages(apkModule.androidManifest.packageName).forEach {
block(it)
}
ApkModuleJsonDecoder(apkModule).decodeResourceTable(workingDir)
}
fun selectConfig(block: OverportConfig.() -> Unit) {
createLibrary("liboverport.config.so") {
val config = runCatching {
json.decodeFromString<OverportConfig>(readText())
}.getOrElse {
OverportConfig(version = 1)
}
block(config)
val jsonString = json.encodeToString(config)
writeText(jsonString)
}
}
fun selectLibrary(fileName: String, block: FileTransformer.() -> Unit) {
listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64").forEach { arch ->
val archDirectory = workingDir.resolve("root/lib/$arch")
@@ -0,0 +1,50 @@
package moe.crx.overport.patching
import moe.crx.overport.patches.*
object PatchStore {
private val PATCHES = mutableMapOf<String, Patch>()
private fun register(patch: Patch) {
if (PATCHES[patch.name] != null) {
throw IllegalStateException("Patch with name ${patch.name} is already registered.")
}
PATCHES[patch.name] = patch
}
fun all(): List<Patch> {
return PATCHES.values.toList()
}
fun get(name: String): Patch? {
return PATCHES[name]
}
fun recommended(): List<Patch> {
return all().filter { it.isRecommended }
}
init {
register(PATCH_COPY_LIBRARIES)
register(PATCH_COPY_OVRPLUGIN_VRAPI)
register(PATCH_REPLACE_ICON_LABEL)
register(PATCH_FIX_MIN_ANDROID_SDK)
register(PATCH_GENERATE_CONFIG)
register(PATCH_REMOVE_LOCALIZED_NAMES)
register(PATCH_CLEAN_UP_FRIDA)
register(PATCH_OCULUS_UNITY)
register(PATCH_OCULUS_UNREAL)
register(PATCH_VR_METADATA)
register(PATCH_LAUNCHER_ENTRY)
register(PATCH_REMOVE_USES_LIBRARY)
register(PATCH_FIX_UNREAL_CRASH)
register(PATCH_META_XR_AUDIO)
register(PATCH_MARK_AS_DEBUGGABLE)
register(PATCH_MARK_ALLOW_BACKUP)
register(PATCH_REMOVE_UNREAL_FORCE_QUIT)
register(PATCH_FORCE_PASSTHROUGH)
register(PATCH_DISABLE_SPACE_WARP)
register(PATCH_DISABLE_CONTROLLER_OFFSET)
}
}
@@ -0,0 +1,266 @@
package moe.crx.overport.patching
import com.android.apksig.ApkSigner
import com.reandroid.apk.*
import com.reandroid.archive.ArchiveFile
import com.reandroid.archive.FileInputSource
import com.reandroid.archive.io.ZipFileInput
import moe.crx.overport.config.OverportPatchedInfo
import moe.crx.overport.utils.NameFormatter
import moe.crx.overport.versions.VersionManager
import org.jf.baksmali.Baksmali
import org.jf.baksmali.BaksmaliOptions
import org.jf.dexlib2.Opcodes
import org.jf.dexlib2.VersionMap
import org.jf.dexlib2.dexbacked.DexBackedDexFile
import org.jf.dexlib2.dexbacked.raw.HeaderItem
import org.jf.smali.Smali
import org.jf.smali.SmaliOptions
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.nio.file.Files
import java.nio.file.StandardCopyOption.REPLACE_EXISTING
class PatcherContext(
private var overportVersion: String,
val patcherDirectory: File,
val inputFileName: String,
val nameFormat: String,
) {
val dataDir: File = patcherDirectory.resolve("dec")
val namespace: String = "working"
val inputApk: File = dataDir.resolve("$namespace.apk")
val outputApk: File = dataDir.resolve("$namespace.output.apk")
val workingDir: File = dataDir.resolve(namespace)
var workingApk: ApkModule? = null
fun isApkLoaded(): Boolean {
return workingApk != null
}
fun overportVersion(): String {
return overportVersion
}
fun outputFileName(): String {
return NameFormatter.format(nameFormat, this)
}
fun checkout() {
var successful = VersionManager(patcherDirectory).checkout(overportVersion)
if (successful == null) {
successful = VersionManager(patcherDirectory).checkout("latest")
}
checkNotNull(successful)
overportVersion = successful
}
fun prepare(stream: InputStream) {
dataDir.mkdirs()
workingDir.deleteRecursively()
inputApk.delete()
outputApk.delete()
Files.copy(stream, inputApk.toPath(), REPLACE_EXISTING)
val zipInput = ZipFileInput(inputApk)
val apk = runCatching {
val archive = ArchiveFile(zipInput)
ApkModule("base", archive.createZipEntryMap()).apply {
apkSignatureBlock = archive.apkSignatureBlock
setCloseable(archive)
}
}.onFailure {
zipInput.close()
}.getOrThrow()
val decoder = ApkModuleJsonDecoder(apk).apply {
dexDecoder = DexDecoder { dexFile, dir ->
val bytes = ByteArrayOutputStream().apply {
dexFile.write(this)
}.toByteArray()
val version = HeaderItem.getVersion(bytes, 0)
val api = VersionMap.mapDexVersionToApi(version)
val opcodes = Opcodes.forApi(api)
val classesName = dexFile.dexNumber.let { if (it != 0) "classes$it" else "classes" }
val smaliDir = dir.resolve("smali").resolve(classesName)
val options = BaksmaliOptions().apply {
localsDirective = true
sequentialLabels = true
skipDuplicateLineNumbers = true
apiLevel = api
}
Baksmali.disassembleDexFile(DexBackedDexFile(opcodes, bytes), smaliDir, 4, options)
}
}
decoder.sanitizeFilePaths()
decoder.decode(workingDir)
workingApk = apk
}
fun patch(
args: Map<String, List<String>>,
callback: (Int, Patch) -> Unit = { _, _ -> }
) {
val librariesDir = VersionManager(patcherDirectory).getLibraries(overportVersion)
val executor = PatchExecutor(librariesDir, workingDir, apk())
val patches = args.keys.mapNotNull { PatchStore.get(it) }
patches.forEachIndexed { index, patch ->
callback(index, patch)
patch.executor(executor, args[patch.name] ?: listOf())
}
executor.selectConfig {
patched = OverportPatchedInfo(
by = VersionManager.VERSION,
with = overportVersion,
patches = patches.map { it.name }
)
}
}
fun export(stream: OutputStream) {
val encoder = ApkModuleJsonEncoder().apply {
dexEncoder = DexEncoder { _, dir ->
dir
.resolve("smali")
.listFiles()
?.filter { file -> file.isDirectory }
?.map {
dir.resolve(".cache").mkdirs()
val file = dir.resolve(".cache").resolve("${it.name}.dex")
val smaliOptions = SmaliOptions().apply {
outputDexFile = file.absolutePath
jobs = 4
apiLevel = 29
}
Smali.assemble(smaliOptions, it.absolutePath)
FileInputSource(file, file.name)
}
?.toList()
}
}
encoder.scanDirectory(workingDir)
encoder.apkModule.run {
zipEntryMap.autoSortApkFiles()
writeApk(outputApk)
close()
}
val signatureDir = patcherDirectory.resolve("signatures")
signatureDir.mkdirs()
val signature = appPackage()
.plus(".keystore")
.let { signatureDir.resolve(it) }
.let { SignatureStore.getSignature(it) }
val tempFile = outputApk
.parentFile
?.resolve(outputApk.name + ".temp")
?.let {
it.delete()
outputApk.copyTo(it)
}
ApkSigner
.Builder(listOf(signature))
.setAlignFileSize(true)
.setMinSdkVersion(29)
.setInputApk(tempFile)
.setOutputApk(outputApk)
.build()
.sign()
tempFile?.delete()
Files.copy(outputApk.toPath(), stream)
}
fun cleanup() {
workingDir.deleteRecursively()
workingApk?.close()
workingApk = null
inputApk.delete()
outputApk.delete()
}
private fun apk(): ApkModule {
val apk = workingApk
checkNotNull(apk)
return apk
}
fun appPackage(): String {
return apk().androidManifest.packageName
}
fun appVersionCode(): String {
return apk().androidManifest.versionCode.toString()
}
fun appVersionName(): String {
return apk().androidManifest.versionName
?: apk().androidManifest.versionCode.toString()
}
fun appName(): String {
return apk().androidManifest.applicationLabelString
?: apk().tableBlock
.getEntries(workingApk?.androidManifest?.applicationLabelReference ?: 0)
.asSequence()
.first()
.valueAsString
}
fun appIcon(): File? {
val iconId = apk().androidManifest.iconResourceId.takeIf { it != 0 }
?: apk().androidManifest.roundIconResourceId.takeIf { it != 0 }
?: return null
val result = apk().listResFiles(iconId, null)
?: return null
// TODO layered adaptive icons support
arrayOf(
"-xxxhdpi",
"-xxhdpi",
"-xhdpi",
"-hdpi",
"-mdpi",
"-ldpi",
"-anydpi",
""
).forEach { dpi ->
arrayOf(".webp", ".png").forEach { ext ->
result.firstOrNull { it.filePath.contains(dpi) && it.filePath.endsWith(ext) }?.let {
workingDir.resolve("root").resolve(it.filePath).run {
if (exists()) {
return this
}
}
}
}
}
return null
}
}
@@ -1,4 +1,4 @@
package moe.crx.ovrport.patch
package moe.crx.overport.patching
import com.android.apksig.ApkSigner.SignerConfig
import com.android.apksig.KeyConfig
@@ -0,0 +1,9 @@
package moe.crx.overport.utils
import java.io.File
object DesktopUtil {
fun defaultWorkspace(): File {
return File(System.getProperty("user.home")).resolve("overport/cache")
}
}
@@ -1,11 +1,13 @@
package moe.crx.ovrport.utils
package moe.crx.overport.utils
import java.net.HttpURLConnection
import java.net.URI
object HttpUtil {
fun download(url: String): ByteArray {
fun download(url: String, timeout: Int = 5000): ByteArray {
val uri = URI(url).toURL().openConnection() as HttpURLConnection
uri.readTimeout = timeout
uri.connectTimeout = 5000
val bytes = uri.errorStream?.use {
it.readBytes()
@@ -0,0 +1,29 @@
package moe.crx.overport.utils
import moe.crx.overport.patches.IconResizer
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import javax.imageio.ImageIO
object ImageIOIconResizer : IconResizer {
override fun resize(bytes: ByteArray, width: Int, height: Int): ByteArray {
val original = ImageIO.read(ByteArrayInputStream(bytes))
val resized = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
resized.createGraphics().apply {
setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC)
setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
drawImage(original, 0, 0, width, height, null)
dispose()
}
val baos = ByteArrayOutputStream()
ImageIO.write(resized, "png", baos)
return baos.toByteArray()
}
}
@@ -1,4 +1,4 @@
package moe.crx.ovrport.utils
package moe.crx.overport.utils
import com.reandroid.json.JSONArray
import com.reandroid.json.JSONObject
@@ -22,15 +22,12 @@ inline fun <reified T> JSONArray?.take(index: Int, block: T?.() -> T? = { this }
}
inline fun <reified T> JSONObject?.takeEach(
condition: T?.() -> Boolean = { true },
block: T?.() -> T? = { this }
condition: T?.() -> Boolean = { true }, block: T?.() -> T? = { this }
): JSONObject? {
this?.keys()?.asSequence()?.toList()
?.forEach { i ->
this?.keys()?.asSequence()?.toList()?.forEach { i ->
elem<T>(i).let {
if (condition(it)) {
block(it)?.let { value -> put(i, value) }
?: remove(i)
block(it)?.let { value -> put(i, value) } ?: remove(i)
}
}
}
@@ -39,15 +36,12 @@ inline fun <reified T> JSONObject?.takeEach(
}
inline fun <reified T> JSONArray?.takeEach(
condition: T?.() -> Boolean = { true },
block: T?.() -> T? = { this }
condition: T?.() -> Boolean = { true }, block: T?.() -> T? = { this }
): JSONArray? {
this?.length()?.let { (it - 1).downTo(0) }
?.forEach { i ->
this?.length()?.let { (it - 1).downTo(0) }?.forEach { i ->
elem<T>(i).let {
if (condition(it)) {
block(it)?.let { value -> put(i, value) }
?: remove(i)
block(it)?.let { value -> put(i, value) } ?: remove(i)
}
}
}
@@ -56,17 +50,11 @@ inline fun <reified T> JSONArray?.takeEach(
}
inline fun <reified T> JSONObject?.elemEach(condition: T?.() -> Boolean = { true }): List<T> {
return this?.keys()?.asSequence()?.toList()
?.mapNotNull { i -> elem<T>(i) }
?.filter { condition(it) }
?.toList()
return this?.keys()?.asSequence()?.toList()?.mapNotNull { i -> elem<T>(i) }?.filter { condition(it) }?.toList()
?: listOf()
}
inline fun <reified T> JSONArray?.elemEach(condition: T?.() -> Boolean = { true }): List<T> {
return this?.length()?.let { (it - 1).downTo(0) }
?.mapNotNull { i -> elem<T>(i) }
?.filter { condition(it) }
?.toList()
?: listOf()
return this?.length()?.let { (it - 1).downTo(0) }?.mapNotNull { i -> elem<T>(i) }?.filter { condition(it) }
?.toList() ?: listOf()
}
@@ -0,0 +1,36 @@
package moe.crx.overport.utils
import com.reandroid.json.JSONArray
import com.reandroid.json.JSONObject
fun JSONObject?.named(name: String?): Boolean {
return elem<String>("name") == name
}
fun JSONObject?.takeNodes(block: JSONArray?.() -> JSONArray? = { this }): JSONObject? {
return take("nodes", block)
}
fun JSONObject?.takeAttributes(block: JSONArray?.() -> JSONArray? = { this }): JSONObject? {
return take("attributes", block)
}
fun JSONObject?.takeNodesEach(
condition: JSONObject?.() -> Boolean = { true }, block: JSONObject?.() -> JSONObject? = { this }
): JSONObject? {
return takeNodes {
takeEach(condition, block)
}
}
fun JSONObject?.takeAttributesEach(
condition: JSONObject?.() -> Boolean = { true }, block: JSONObject?.() -> JSONObject? = { this }
): JSONObject? {
return takeAttributes {
takeEach(condition, block)
}
}
fun JSONObject?.nameAttribute(): String? {
return elem<JSONArray>("attributes").elemEach<JSONObject> { named("name") }.firstOrNull().elem<String>("data")
}
@@ -0,0 +1,20 @@
package moe.crx.overport.utils
import moe.crx.overport.patching.PatcherContext
object NameFormatter {
const val DEFAULT_FORMAT = "{filename}.output.{ext}"
fun format(format: String, context: PatcherContext): String {
return (format.takeIf { it.isNotBlank() } ?: DEFAULT_FORMAT)
.replace("{package}", context.appPackage())
.replace("{label}", context.appName())
.replace("{code}", context.appVersionCode())
.replace("{version}", context.appVersionName())
.replace("{overport}", context.overportVersion())
.replace("{filename}", context.inputFileName.substringBeforeLast('.'))
.replace("{ext}", context.inputFileName.substringAfterLast('.'))
.replace("{filenameext}", context.inputFileName)
}
}
@@ -0,0 +1,68 @@
package moe.crx.overport.utils
import kotlinx.coroutines.*
import kotlin.math.max
object PrintUtil {
const val PROGRESS_LENGTH = 20L
var progressTick = 0
val scope = CoroutineScope(Dispatchers.IO)
fun updateLine(block: () -> Unit): Job {
return scope.launch {
while (isActive) {
drawLine(block)
delay(300)
}
}
}
fun drawLine(block: () -> Unit) {
block()
progressTick++
print("\r")
}
fun drawSpinner() {
val progressSpinner = when (progressTick % 4) {
0 -> '|'
1 -> '/'
2 -> '-'
3 -> '\\'
else -> '*'
}
print(progressSpinner)
print(" ")
}
fun drawProgressBar(done: Long, total: Long?) {
print("[")
val doneChars = if (total != null) done * PROGRESS_LENGTH / total else 0
(1..doneChars).forEach { _ ->
print('#')
}
if (doneChars != PROGRESS_LENGTH) {
val plusPosition = progressTick % (PROGRESS_LENGTH - doneChars)
(1..plusPosition).forEach { _ ->
print(' ')
}
print('+')
(1..PROGRESS_LENGTH - 1 - plusPosition - doneChars).forEach { _ ->
print(' ')
}
}
print("] ")
}
fun fspace(): String {
return " "
}
fun uspace(key: Any, list: Collection<Any>): String {
val max = list.map { it.toString().length }.maxOfOrNull { it } ?: 0
return " ".repeat(max(max - key.toString().length, 0) + 4)
}
}
@@ -0,0 +1,24 @@
package moe.crx.overport.versions
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class OverportRelease(
@SerialName("version")
var version: String,
@SerialName("app_required")
var appRequired: String? = null,
@SerialName("is_experimental")
var isExperimental: Boolean = false,
@SerialName("download_url")
var downloadUrl: String? = null,
@SerialName("is_custom")
var isCustom: Boolean = false,
)
@Serializable
data class ReleasesIndex(
@SerialName("releases")
var releases: List<OverportRelease>,
)
@@ -0,0 +1,158 @@
package moe.crx.overport.versions
import kotlinx.serialization.json.Json
import moe.crx.overport.utils.HttpUtil
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
class VersionManager(val workingDirectory: File) {
companion object {
val json = Json { ignoreUnknownKeys = true }
const val VERSION = "1.2.0"
private fun isVersionLower(left: String, right: String): Boolean {
val (lY, lM, lD) = left.substringBeforeLast('-').split('.').map { it.toInt() }
val (rY, rM, rD) = right.substringBeforeLast('-').split('.').map { it.toInt() }
return lY < rY || lM < rM || lD < rD || left < right
}
fun isIncompatible(release: OverportRelease): Boolean {
return release.appRequired?.let { isVersionLower(VERSION, it) } ?: false
}
}
private fun appendInstalled(version: OverportRelease) {
val file = File(workingDirectory, "installed.json")
val installed = (installed() + version).distinctBy { it.version }
val text = json.encodeToString(ReleasesIndex(installed))
file.writeText(text)
}
fun uninstall(version: String) {
val file = File(workingDirectory, "installed.json")
val installed = installed()
val selected = versionSelector(version, installed, true) ?: return
val processed = installed.filter { it.version != selected.version }
val text = json.encodeToString(ReleasesIndex(processed))
val librariesDir = workingDirectory.resolve("libraries/${selected.version}")
if (librariesDir.isDirectory) {
librariesDir.deleteRecursively()
}
file.writeText(text)
}
private fun versionSelector(
version: String,
releases: List<OverportRelease>,
ignoreCompatibility: Boolean
): OverportRelease? {
val check: (OverportRelease) -> Boolean = {
!isIncompatible(it) || ignoreCompatibility
}
return when (version) {
"experimental" -> {
releases.firstOrNull { it.isExperimental && check(it) }
}
"latest" -> {
releases.firstOrNull { check(it) }
}
else -> {
releases.firstOrNull { it.version == version && check(it) }
}
}
}
fun checkout(version: String): String? {
val availableEntry = versionSelector(version, available(), false)
val installedEntry = availableEntry ?: versionSelector(version, installed(), false)
if (installedEntry != null) {
val librariesDir = workingDirectory.resolve("libraries/${installedEntry.version}")
if (librariesDir.isDirectory) {
return installedEntry.version
}
}
val entry = availableEntry ?: return null
val downloadUrl = entry.downloadUrl ?: return null
val librariesDir = workingDirectory.resolve("libraries/${entry.version}")
val downloadedArchive = runCatching {
HttpUtil.download(downloadUrl)
}.getOrElse {
librariesDir.deleteRecursively()
return null
}
librariesDir.mkdirs()
ZipInputStream(downloadedArchive.inputStream()).use { zip ->
generateSequence { zip.nextEntry }.forEach {
if (it.isDirectory) {
librariesDir.resolve(it.name).mkdirs()
} else {
zip.copyTo(FileOutputStream(librariesDir.resolve(it.name)))
}
}
}
appendInstalled(entry)
return entry.version
}
fun getLibraries(version: String): File {
val entry = installed().firstOrNull { it.version == version }
checkNotNull(entry)
check(!isIncompatible(entry))
return workingDirectory.resolve("libraries/${entry.version}")
}
fun installed(): List<OverportRelease> {
val file = File(workingDirectory, "installed.json")
runCatching {
val resultBytes = file.readBytes()
val index = json.decodeFromString<ReleasesIndex>(String(resultBytes))
return index.releases.sortedWith(Comparator { o1, o2 ->
if (isVersionLower(
o1.version,
o2.version
)
) 1 else -1
})
}
return listOf()
}
fun available(): List<OverportRelease> {
val url = "https://ovrp.crx.moe/api/v1/releases/index"
runCatching {
val resultBytes = HttpUtil.download(url)
val index = json.decodeFromString<ReleasesIndex>(String(resultBytes))
return index.releases.sortedWith(Comparator { o1, o2 ->
if (isVersionLower(
o1.version,
o2.version
)
) 1 else -1
})
}
return listOf()
}
}
+5
View File
@@ -13,6 +13,10 @@ pluginManagement {
mavenCentral()
gradlePluginPortal()
}
plugins {
kotlin("jvm") version "2.2.0"
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
}
dependencyResolutionManagement {
@@ -29,3 +33,4 @@ dependencyResolutionManagement {
}
include(":composeApp")
include(":overportcli")