feat: Initial commit

feat: Main screen

feat: Patcher beginnings

chore: Rename project

feat: Bottom navigation bar

chore: Adaptive icon

feat: Shaping patching

feat: Integrate aapt2

feat: Integrating ARSCLib

feat: JSON manifest processing

feat: Migrating to KMP

feat: Generate ovrport config

feat: Remove localized app names

feat: Drag and drop

refactor: Improve reliability

chore: Prepare for release

feat: Update detection

chore: README and LICENSE

feat: Replace smali compiler

chore: Code cleanup
This commit is contained in:
curoviyxru authored and Mathew Tkachuk committed 2025-03-07 19:07:08 +03:00
commit 5fc0e8fef4
52 files changed
+3344

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val appVersion: String by project
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
jvm("desktop")
sourceSets {
val desktopMain by getting
androidMain.dependencies {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
}
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodel)
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)
implementation(libs.kotlinx.coroutines.swing)
}
}
}
android {
namespace = "moe.crx.ovrport"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
applicationId = "moe.crx.ovrport"
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() }
versionName = appVersion
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
}
}
buildTypes {
release {
isMinifyEnabled = false
isShrinkResources = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
dependencies {
debugImplementation(compose.uiTooling)
}
compose.desktop {
application {
mainClass = "moe.crx.ovrport.MainKt"
buildTypes {
release {
proguard {
configurationFiles.from("proguard-rules.pro")
}
}
}
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "overport"
packageVersion = appVersion
windows {
shortcut = true
perUserInstall = true
iconFile.set(file("icons/app-icon.ico"))
}
linux {
shortcut = true
iconFile.set(file("icons/app-icon.png"))
}
macOS {
dockName = "overport"
iconFile.set(file("icons/app-icon.icns"))
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.
Binary file not shown.
View File
Whitespace-only changes.
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:extractNativeLibs="true"
android:theme="@style/Theme.Overport"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:icon="@mipmap/ic_launcher"
android:exported="true"
android:theme="@style/Theme.Overport">
<intent-filter>
<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>
<meta-data android:name="isPUI" android:value="1"/>
<meta-data android:name="pvr.vrshell.mode" android:value="1"/>
</application>
</manifest>
@@ -0,0 +1,119 @@
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()
}
},
)
}
@@ -0,0 +1,5 @@
package moe.crx.ovrport
import android.os.Build
actual fun getPlatform(): Platform = AndroidPlatform("Android ${Build.VERSION.SDK_INT}")
@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group android:scaleX="0.6"
android:scaleY="0.6"
android:translateX="4.8"
android:translateY="4.8">
<path
android:pathData="m17.66,4.873l0,-0C17.963,5.048 18.042,5.452 17.824,5.73L13.874,10.77L16.906,12.52C17.668,12.96 16.816,13.335 16.784,13.351 14.578,14.652 11.291,16.565 6.926,19.125 6.749,19.231 6.521,19.226 6.348,19.126l0,-0c-0.303,-0.175 -0.382,-0.578 -0.164,-0.857L10.134,13.229l-3.04,-1.755c-0.346,-0.2 -0.442,-0.475 -0.016,-0.772 5.719,-3.326 9.048,-5.272 10.005,-5.829 0.177,-0.106 0.396,-0.106 0.578,-0.001z"
android:fillColor="#ffffff"/>
</group>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
</resources>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">overport</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Overport" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@android:color/transparent</item>
</style>
</resources>
@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="77.095dp"
android:height="16.497dp"
android:viewportWidth="77.095"
android:viewportHeight="16.497">
<path
android:fillColor="#FF000000"
android:pathData="m9.849,7.944q0,1.181 -0.343,2.172 -0.343,0.972 -0.991,1.676 -0.648,0.686 -1.562,1.067 -0.895,0.381 -2.019,0.381 -1.105,0 -2.019,-0.381 -0.895,-0.381 -1.543,-1.067 -0.648,-0.705 -1.01,-1.676 -0.362,-0.991 -0.362,-2.172t0.362,-2.153q0.381,-0.972 1.029,-1.657 0.667,-0.686 1.562,-1.067 0.914,-0.381 1.981,-0.381 1.086,0 1.981,0.381 0.914,0.381 1.562,1.067t1.01,1.657 0.362,2.153zM6.953,7.944q0,-1.314 -0.533,-2.057 -0.514,-0.762 -1.486,-0.762t-1.505,0.762q-0.533,0.743 -0.533,2.057 0,1.314 0.533,2.095 0.533,0.762 1.505,0.762t1.486,-0.762q0.533,-0.781 0.533,-2.095zM14.497,12.973q-0.972,-1.905 -1.981,-4.458 -0.991,-2.553 -1.886,-5.563h3.01q0.191,0.743 0.438,1.619 0.267,0.857 0.533,1.753 0.286,0.876 0.552,1.714 0.286,0.838 0.552,1.524 0.248,-0.686 0.533,-1.524 0.286,-0.838 0.552,-1.714 0.286,-0.895 0.533,-1.753 0.267,-0.876 0.457,-1.619h2.934q-0.895,3.01 -1.905,5.563 -0.991,2.553 -1.962,4.458zM37.433,5.467q-0.381,-0.095 -0.895,-0.191 -0.514,-0.114 -1.105,-0.114 -0.267,0 -0.648,0.057 -0.362,0.038 -0.552,0.095v7.658h-2.838v-9.487q0.762,-0.267 1.791,-0.495 1.048,-0.248 2.324,-0.248 0.229,0 0.552,0.038 0.324,0.019 0.648,0.076 0.324,0.038 0.648,0.114 0.324,0.057 0.552,0.152zM45.853,8.039q0,-1.314 -0.591,-2.095 -0.591,-0.8 -1.753,-0.8 -0.381,0 -0.705,0.038 -0.324,0.019 -0.533,0.057v5.124q0.267,0.171 0.686,0.286 0.438,0.114 0.876,0.114 2.019,0 2.019,-2.724zM48.748,7.963q0,1.162 -0.286,2.115 -0.286,0.952 -0.838,1.638 -0.552,0.686 -1.372,1.067 -0.819,0.381 -1.886,0.381 -0.591,0 -1.105,-0.114 -0.514,-0.114 -0.991,-0.324v3.772h-2.838v-13.201q0.381,-0.114 0.876,-0.21 0.495,-0.114 1.029,-0.191 0.552,-0.076 1.105,-0.114 0.571,-0.057 1.086,-0.057 1.238,0 2.21,0.381 0.972,0.362 1.638,1.048 0.667,0.667 1.01,1.638 0.362,0.972 0.362,2.172zM60.311,7.944q0,1.181 -0.343,2.172 -0.343,0.972 -0.991,1.676 -0.648,0.686 -1.562,1.067 -0.895,0.381 -2.019,0.381 -1.105,0 -2.019,-0.381 -0.895,-0.381 -1.543,-1.067 -0.648,-0.705 -1.01,-1.676 -0.362,-0.991 -0.362,-2.172t0.362,-2.153q0.381,-0.972 1.029,-1.657 0.667,-0.686 1.562,-1.067 0.914,-0.381 1.981,-0.381 1.086,0 1.981,0.381 0.914,0.381 1.562,1.067 0.648,0.686 1.01,1.657 0.362,0.972 0.362,2.153zM57.416,7.944q0,-1.314 -0.533,-2.057 -0.514,-0.762 -1.486,-0.762 -0.972,0 -1.505,0.762 -0.533,0.743 -0.533,2.057 0,1.314 0.533,2.095 0.533,0.762 1.505,0.762 0.972,0 1.486,-0.762 0.533,-0.781 0.533,-2.095zM68.541,5.467q-0.381,-0.095 -0.895,-0.191 -0.514,-0.114 -1.105,-0.114 -0.267,0 -0.648,0.057 -0.362,0.038 -0.552,0.095v7.658h-2.838v-9.487q0.762,-0.267 1.791,-0.495 1.048,-0.248 2.324,-0.248 0.229,0 0.552,0.038 0.324,0.019 0.648,0.076 0.324,0.038 0.648,0.114 0.324,0.057 0.552,0.152zM70.446,0.457 L73.284,-0v2.953h3.41v2.362h-3.41v3.524q0,0.895 0.305,1.429 0.324,0.533 1.276,0.533 0.457,0 0.933,-0.076 0.495,-0.095 0.895,-0.248l0.4,2.21q-0.514,0.21 -1.143,0.362 -0.629,0.152 -1.543,0.152 -1.162,0 -1.924,-0.305 -0.762,-0.324 -1.219,-0.876 -0.457,-0.571 -0.648,-1.372 -0.171,-0.8 -0.171,-1.772z"
android:strokeWidth=".26458"/>
<path
android:fillColor="#FF000000"
android:pathData="m29.849,2.442c0.234,0.135 0.295,0.446 0.126,0.661l-3.046,3.888 2.338,1.35c0.588,0.339 -0.069,0.628 -0.094,0.641 -1.702,1.004 -4.236,2.479 -7.603,4.454 -0.136,0.082 -0.312,0.078 -0.446,0.001 -0.234,-0.135 -0.295,-0.446 -0.126,-0.661l3.046,-3.888 -2.344,-1.354c-0.267,-0.154 -0.341,-0.366 -0.013,-0.595 4.411,-2.565 6.978,-4.066 7.716,-4.495 0.136,-0.082 0.306,-0.082 0.446,-0.001z"
android:strokeWidth=".77121"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="m21.421,0.131c0.505,0.291 0.636,0.964 0.273,1.427l-6.578,8.396l5.049,2.915c1.269,0.733 -0.149,1.357 -0.202,1.383 -3.675,2.167 -9.149,5.354 -16.42,9.618 -0.294,0.176 -0.674,0.168 -0.963,0.002 -0.505,-0.291 -0.636,-0.964 -0.273,-1.427l6.578,-8.396l-5.063,-2.923c-0.577,-0.333 -0.736,-0.79 -0.027,-1.285 9.526,-5.54 15.072,-8.782 16.664,-9.708 0.294,-0.176 0.66,-0.177 0.963,-0.002z"
android:fillColor="#fff"/>
</vector>
@@ -0,0 +1,11 @@
<resources>
<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>
<string name="unknown_package">&lt;unknown package&gt;</string>
<string name="cant_read_apk">Selected APK file cannot be parsed.</string>
<string name="apk_file_exported">APK file has been successfully exported.</string>
<string name="cant_update_overport">overport libraries cannot be downloaded.</string>
<string name="unknown_error">Unknown error has been occurred.</string>
<string name="update_available">Update available!</string>
</resources>
@@ -0,0 +1,14 @@
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.0.0"
@@ -0,0 +1,46 @@
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,
val path: String,
val type: String = "store"
)
@Serializable
data class FrdaParameters(
val assets: List<FrdaAsset>,
val skus: List<String>
)
@Serializable
data class FrdaInteraction(
val parameters: FrdaParameters
)
@Serializable
data class FrdaConfig(
val interaction: 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) })
)
)
)
}
}
@@ -0,0 +1,14 @@
package moe.crx.ovrport.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GithubRelease(
@SerialName("html_url")
var htmlUrl: String,
@SerialName("name")
val name: String,
@SerialName("published_at")
val publishedAt: String,
)
@@ -0,0 +1,66 @@
package moe.crx.ovrport.model
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class OverportConfigLanguage(
@SerialName("english_name")
var englishName: String = "",
@SerialName("native_name")
var nativeName: String = "",
@SerialName("tag")
var tag: String = "",
)
@Serializable
data class OverportConfigAsset(
@SerialName("asset_id")
var assetId: Long = 0,
@SerialName("file_path")
var filePath: String,
@SerialName("asset_type")
var assetType: String = "store",
@SerialName("language")
var language: OverportConfigLanguage = OverportConfigLanguage(),
@SerialName("download_status")
var downloadStatus: String = "installed",
@SerialName("iap_status")
var iapStatus: String = "entitled",
@SerialName("metadata")
var metadata: String = "",
)
@Serializable
data class OverportConfigApplication(
@SerialName("app_id")
var appId: String,
@SerialName("products")
var products: List<String>,
@SerialName("assets")
var assets: List<OverportConfigAsset>,
)
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class OverportConfigV1(
@SerialName("version")
@EncodeDefault
var version: Int = 1,
@SerialName("suggested_texture_width")
var suggestedTextureWidth: Int = 0,
@SerialName("suggested_texture_height")
var suggestedTextureHeight: Int = 0,
@SerialName("suggested_cpu_level")
var suggestedCpuLevel: Int = 0,
@SerialName("suggested_gpu_level")
var suggestedGpuLevel: Int = 0,
@SerialName("vibration_multiplier")
var vibrationMultiplier: Double = 1.0,
@SerialName("vibration_frequency_multiplier")
var vibrationFrequencyMultiplier: Double = 1.0,
@SerialName("database")
var database: List<OverportConfigApplication>,
)
@@ -0,0 +1,9 @@
package moe.crx.ovrport.patch
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"
}
@@ -0,0 +1,5 @@
package moe.crx.ovrport.patch
class CantUpdateOverportException : RuntimeException()
class CantDecodeApkException : RuntimeException()
@@ -0,0 +1,168 @@
package moe.crx.ovrport.patch
import com.reandroid.json.JSONObject
import java.io.File
data class Patch(
val name: String = "",
val desc: String? = null,
val executor: PatchExecutor.() -> Unit
)
class FileTransformer(val file: File) {
fun replace(input: String, output: String) {
file.writeText(Regex(input, RegexOption.DOT_MATCHES_ALL).replace(file.readText(), output))
}
fun find(input: String): Boolean {
return Regex(input, RegexOption.DOT_MATCHES_ALL).find(file.readText()) != null
}
fun append(line: String) {
file.appendText(line)
}
fun readJson(): JSONObject? {
return runCatching { JSONObject(file.readText()) }.getOrNull()
}
fun writeJson(json: JSONObject) {
file.writeText(json.toString())
}
fun replaceHex(input: String, output: String) {
val bytes = file.readBytes()
val inputBytes = input.split(' ')
for (i in 0..<bytes.size-inputBytes.size+1) {
var matches = true
for (j in i..<bytes.size) {
if (inputBytes[j] != "??" && bytes[j] != inputBytes[j].toByte(16)) {
matches = false
break
}
}
if (matches) {
val replaced = bytes.slice(0..<i) +
output.split(' ').map { it.toByte(16) } +
bytes.slice(i+inputBytes.size..<bytes.size)
file.writeBytes(replaced.toByteArray())
return
}
}
}
}
class PatchExecutor(private val workingDir: File) {
fun selectWorkspace(block: FileTransformer.() -> Unit) {
workingDir.run {
if (exists()) {
FileTransformer(this).block()
}
}
}
fun selectFile(fileName: String, block: FileTransformer.() -> Unit) {
workingDir.resolve(fileName).run {
if (exists()) {
FileTransformer(this).block()
}
}
}
fun selectResources(block: FileTransformer.() -> Unit) {
selectFile("resources/resources.arsc.json", block)
}
fun selectManifest(block: FileTransformer.() -> Unit) {
selectFile("AndroidManifest.xml.json", block)
}
fun selectLibrary(fileName: String, block: FileTransformer.() -> Unit) {
listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64").forEach { arch ->
val archDirectory = workingDir.resolve("root/lib/$arch")
if (archDirectory.exists()) {
archDirectory.resolve(fileName).run {
if (exists()) {
FileTransformer(this).block()
}
}
}
}
}
fun createLibrary(fileName: String, block: FileTransformer.() -> Unit) {
listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64").forEach { arch ->
val archDirectory = workingDir.resolve("root/lib/$arch")
if (archDirectory.exists()) {
archDirectory.resolve(fileName).run {
createNewFile()
FileTransformer(this).block()
}
}
}
}
fun selectSmali(vararg fileNames: String, block: FileTransformer.() -> Unit) {
workingDir.resolve("smali").listFiles()?.forEach { dir ->
if (fileNames.isEmpty()) {
dir.walk().forEach { file ->
if (file.isFile && file.name.endsWith(".smali")) {
FileTransformer(file).block()
}
}
} else {
fileNames.forEach { fileName ->
dir.resolve("${fileName}.1.smali").run {
if (exists()) {
FileTransformer(this).block()
}
}
dir.resolve("${fileName}.smali").run {
if (exists()) {
FileTransformer(this).block()
}
}
}
}
}
}
fun createSmali(fileName: String, block: FileTransformer.() -> Unit) {
workingDir.resolve("smali").listFiles()?.forEach { dir ->
dir.resolve("${fileName}.1.smali").run {
if (exists()) {
FileTransformer(this).block()
return
}
}
dir.resolve("${fileName}.smali").run {
if (exists()) {
FileTransformer(this).block()
return
}
}
}
workingDir
.resolve("smali")
.listFiles()
?.reduceOrNull { left, right ->
val leftValue = left.name.substringAfter("classes").toIntOrNull() ?: 0
val rightValue = right.name.substringAfter("classes").toIntOrNull() ?: 0
if (rightValue > leftValue) right else left
}
.let {
it ?: workingDir.resolve("smali/classes").apply { mkdirs() }
}
.resolve("${fileName}.smali")
.apply { createNewFile() }
.run {
if (exists()) {
FileTransformer(this).block()
}
}
}
}
@@ -0,0 +1,241 @@
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") }) {
takeNodesEach({ named("category") }) {
when (attributeName()) {
"android.intent.category.INFO" -> null
"android.intent.category.LAUNCHER" -> null
else -> this
}
}
takeNodes {
this?.put(createCategory("android.intent.category.LAUNCHER"))
}
}
}
}
}?.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 (!find(".*")) {
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("liblibMetaXRAudioWwise.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"
)
}
}
}
}
@@ -0,0 +1,224 @@
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 {
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
}
@@ -0,0 +1,91 @@
package moe.crx.ovrport.patch
import com.android.apksig.ApkSigner.SignerConfig
import com.android.apksig.KeyConfig
import org.bouncycastle.asn1.x500.X500Name
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.math.BigInteger
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PrivateKey
import java.security.SecureRandom
import java.security.cert.Certificate
import java.security.cert.X509Certificate
import java.util.Calendar
import java.util.Date
import java.util.UUID
object SignatureStore {
private const val KS_PASSWORD = "password"
private const val KS_ALIAS = "key"
private fun readSignature(file: File): SignerConfig {
val ks = KeyStore.getInstance(KeyStore.getDefaultType())
FileInputStream(file).use { stream ->
ks.load(stream, KS_PASSWORD.toCharArray())
}
val alias = ks.aliases().nextElement()
val key = ks.getKey(alias, KS_PASSWORD.toCharArray()) as PrivateKey
val cert = ks.getCertificate(alias) as X509Certificate
return SignerConfig.Builder(cert.issuerX500Principal.name, KeyConfig.Jca(key), listOf(cert)).build()
}
private fun generateCertificate(keyPair: KeyPair): Certificate {
val now = System.currentTimeMillis()
val startDate = Date(now)
val expireDate = Calendar.getInstance().run {
time = startDate
add(Calendar.YEAR, 25)
time
}
val dnName = UUID
.randomUUID()
.toString()
.replace("-", "")
.prependIndent("CN=")
.let { X500Name(it) }
val serial = BigInteger(64, SecureRandom())
val signer = JcaContentSignerBuilder("SHA256withRSA").setProvider(BouncyCastleProvider()).build(keyPair.private)
return JcaX509v3CertificateBuilder(dnName, serial, startDate, expireDate, dnName, keyPair.public).build(signer).let {
JcaX509CertificateConverter().setProvider(BouncyCastleProvider()).getCertificate(it)
}
}
private fun createSignature(file: File) {
val ks = KeyStore.getInstance(KeyStore.getDefaultType())
ks.load(null, KS_PASSWORD.toCharArray())
val keyPair = KeyPairGenerator
.getInstance("RSA", BouncyCastleProvider())
.apply { initialize(2048) }
.generateKeyPair()
val cert = generateCertificate(keyPair)
ks.setKeyEntry(KS_ALIAS, keyPair.private, KS_PASSWORD.toCharArray(), arrayOf(cert))
FileOutputStream(file).use { stream ->
ks.store(stream, KS_PASSWORD.toCharArray())
}
}
fun getSignature(file: File): SignerConfig {
if (!file.exists()) {
createSignature(file)
}
return readSignature(file)
}
}
@@ -0,0 +1,152 @@
package moe.crx.ovrport.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.material.icons.filled.Download
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.delay
import kotlinx.coroutines.withContext
import moe.crx.ovrport.AndroidPlatform
import moe.crx.ovrport.getCurrentVersion
import moe.crx.ovrport.patch.Patch
import moe.crx.ovrport.getPlatform
import moe.crx.ovrport.model.GithubRelease
import moe.crx.ovrport.patch.Constants
import moe.crx.ovrport.ui.theme.OverportTheme
import moe.crx.ovrport.utils.HttpUtil
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,
color = MaterialTheme.colorScheme.secondary
)
}
},
actions = {
AnimatedVisibility(
versionToUpdate?.name != null && versionToUpdate?.name != getCurrentVersion(),
enter = fadeIn(),
exit = fadeOut()
) {
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,
color = MaterialTheme.colorScheme.secondary,
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
AnimatedVisibility(
!isPatcherVisible,
enter = fadeIn(),
exit = fadeOut()
) {
StartingCard(innerPadding, inProgress) {
onOpen()
}
}
AnimatedVisibility(
isPatcherVisible,
enter = fadeIn(),
exit = fadeOut()
) {
ApplicationInfoCard(
innerPadding,
viewModel.currentAppName(),
viewModel.currentAppPackage(),
viewModel.currentAppVersion(),
viewModel.currentAppIcon(),
onCancel = {
onCancel()
},
onConfirm = {
onConfirm(it)
}
)
}
}
}
}
@@ -0,0 +1,138 @@
package moe.crx.ovrport.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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
import androidx.compose.material.icons.filled.Widgets
import androidx.compose.material3.*
import org.jetbrains.compose.resources.stringResource
import overportapp.composeapp.generated.resources.Res
import overportapp.composeapp.generated.resources.unknown_package
@OptIn(ExperimentalLayoutApi::class)
@Composable
@Preview
fun ApplicationInfoCard(
innerPadding: PaddingValues = PaddingValues(16.dp),
applicationName: String? = "Application",
applicationPackage: String? = "com.company.application",
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.padding(16.dp),
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),
color = MaterialTheme.colorScheme.secondary
)
}
}
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")
}
}
}
}
@@ -0,0 +1,95 @@
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()
}
}
@@ -0,0 +1,155 @@
package moe.crx.ovrport.ui
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircle
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.CornerRadius
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 kotlinx.coroutines.delay
import moe.crx.ovrport.AndroidPlatform
import moe.crx.ovrport.getPlatform
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
@Composable
@Preview
fun StartingCard(
innerPadding: PaddingValues = PaddingValues(16.dp),
showLoading: Boolean = false,
onCardClick: () -> Unit = {}
) {
val strokeColor = LocalContentColor.current
Card(
modifier = Modifier
.padding(innerPadding)
.padding(16.dp)
.fillMaxSize()
.drawBehind {
drawRoundRect(
color = strokeColor,
style = Stroke(
width = 4.dp.toPx(),
pathEffect = PathEffect.dashPathEffect(
floatArrayOf(
16.dp.toPx(),
16.dp.toPx()
), 16.dp.toPx()
)
),
cornerRadius = CornerRadius(32.dp.toPx())
)
}
.clip(RoundedCornerShape(32.dp))
.clickable(!showLoading, onClick = onCardClick),
colors = CardDefaults.cardColors(
containerColor = Color.Transparent
)
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (!showLoading) {
Icon(
Icons.Default.Download,
contentDescription = null,
modifier = Modifier
.padding(16.dp)
.width(64.dp)
.height(64.dp)
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(Res.string.select_apk_to_begin)
)
if (getPlatform() !is AndroidPlatform) {
Text(
text = stringResource(Res.string.or_drag_and_drop_it)
)
}
}
} else {
BreathingIcon(
modifier = Modifier
.padding(16.dp)
.rotate(30f)
.width(96.dp)
.height(96.dp)
)
Text(
text = stringResource(Res.string.decoding_apk)
)
}
}
}
}
@Composable
fun BreathingIcon(modifier: Modifier, duration: Int = 1500) {
var isFaded by remember { mutableStateOf(false) }
val alpha by animateFloatAsState(
targetValue = if (isFaded) 0.2f else 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = duration, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = ""
)
LaunchedEffect(Unit) {
while (true) {
isFaded = !isFaded
delay(duration.toLong())
}
}
Icon(
Icons.Default.Bolt,
contentDescription = null,
modifier = modifier.alpha(alpha)
)
}
@@ -0,0 +1,11 @@
package moe.crx.ovrport.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,46 @@
package moe.crx.ovrport.ui.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
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun OverportTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = when {
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,34 @@
package moe.crx.ovrport.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,19 @@
package moe.crx.ovrport.utils
import java.net.HttpURLConnection
import java.net.URI
object HttpUtil {
fun download(url: String): ByteArray {
val uri = URI(url).toURL().openConnection() as HttpURLConnection
val bytes = uri.errorStream?.use {
it.readBytes()
} ?: uri.inputStream.use {
it.readBytes()
}
uri.disconnect()
return bytes
}
}
@@ -0,0 +1,72 @@
package moe.crx.ovrport.utils
import com.reandroid.json.JSONArray
import com.reandroid.json.JSONObject
inline fun <reified T> JSONObject?.elem(name: String): T? {
return this?.opt(name).takeIf { it is T } as T?
}
inline fun <reified T> JSONArray?.elem(index: Int): T? {
return this?.opt(index).takeIf { it is T } as T?
}
inline fun <reified T> JSONObject?.take(name: String, block: T?.() -> T? = { this }): JSONObject? {
this?.put(name, block(elem<T>(name)))
return this
}
inline fun <reified T> JSONArray?.take(index: Int, block: T?.() -> T? = { this }): JSONArray? {
this?.put(index, block(elem<T>(index)))
return this
}
inline fun <reified T> JSONObject?.takeEach(
condition: T?.() -> Boolean = { true },
block: T?.() -> T? = { this }
): JSONObject? {
this?.keys()?.asSequence()?.toList()
?.forEach { i ->
elem<T>(i).let {
if (condition(it)) {
block(it)?.let { value -> put(i, value) }
?: remove(i)
}
}
}
return this
}
inline fun <reified T> JSONArray?.takeEach(
condition: T?.() -> Boolean = { true },
block: T?.() -> T? = { this }
): JSONArray? {
this?.length()?.let { (it - 1).downTo(0) }
?.forEach { i ->
elem<T>(i).let {
if (condition(it)) {
block(it)?.let { value -> put(i, value) }
?: remove(i)
}
}
}
return this
}
inline fun <reified T> JSONObject?.elemEach(condition: T?.() -> Boolean = { true }): List<T> {
return this?.keys()?.asSequence()?.toList()
?.mapNotNull { i -> elem<T>(i) }
?.filter { condition(it) }
?.toList()
?: listOf()
}
inline fun <reified T> JSONArray?.elemEach(condition: T?.() -> Boolean = { true }): List<T> {
return this?.length()?.let { (it - 1).downTo(0) }
?.mapNotNull { i -> elem<T>(i) }
?.filter { condition(it) }
?.toList()
?: listOf()
}
@@ -0,0 +1,88 @@
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)
)
)
}
@@ -0,0 +1,3 @@
package moe.crx.ovrport
actual fun getPlatform(): Platform = JVMPlatform("Java ${System.getProperty("java.version")}")
@@ -0,0 +1,157 @@
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()
}
},
)
}
}
}