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