Compare commits

...

25 Commits

Author SHA1 Message Date
semantic-release-bot
7615453eec chore: Release v1.26.0-dev.20 [skip ci]
# app [1.26.0-dev.20](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.19...v1.26.0-dev.20) (2026-01-09)

### Bug Fixes

* Save FAB freaking out in select patches screen ([4c0b6b0](4c0b6b02e9))
2026-01-09 20:41:33 +00:00
Ax333l
4c0b6b02e9 fix: Save FAB freaking out in select patches screen 2026-01-09 21:33:08 +01:00
Ax333l
fe84b22b6f chore: update dependencies and fix deprecations 2026-01-09 19:36:04 +01:00
semantic-release-bot
1b21f5d4ab chore: Release v1.26.0-dev.19 [skip ci]
# app [1.26.0-dev.19](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.18...v1.26.0-dev.19) (2026-01-08)

### Bug Fixes

* **locales:** use buildconfig instead of generating kt file ([72b1db9](72b1db9a2f))
2026-01-08 22:35:21 +00:00
Ax333l
72b1db9a2f fix(locales): use buildconfig instead of generating kt file 2026-01-08 23:27:02 +01:00
semantic-release-bot
2805ac6540 chore: Release v1.26.0-dev.18 [skip ci]
# app [1.26.0-dev.18](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.17...v1.26.0-dev.18) (2026-01-08)

### Bug Fixes

* Prevent trailing comma when no locales are generated ([b16931c](b16931ca79))

### Features

* Add language settings ([#2913](https://github.com/ReVanced/revanced-manager/issues/2913)) ([df31b39](df31b39cc8))
2026-01-08 21:12:25 +00:00
Robert
b16931ca79 fix: Prevent trailing comma when no locales are generated 2026-01-08 22:04:03 +01:00
Ushie
dfeca09d00 ci: Switch to using crowdin.yml to specify filename 2026-01-07 23:50:57 +03:00
Ushie
44c06e2197 ci: Use a clearer file name for source file to display in Crowdin 2026-01-07 23:43:40 +03:00
Ushie
df31b39cc8 feat: Add language settings (#2913) 2026-01-07 22:54:48 +03:00
semantic-release-bot
25d82e869c chore: Release v1.26.0-dev.17 [skip ci]
# app [1.26.0-dev.17](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.16...v1.26.0-dev.17) (2026-01-06)

### Bug Fixes

* allow updating patches on metered networks ([9d9a0e8](9d9a0e81db))
2026-01-06 21:45:09 +00:00
Ax333l
9d9a0e81db fix: allow updating patches on metered networks 2026-01-06 22:37:25 +01:00
semantic-release-bot
ffa42099e3 chore: Release v1.26.0-dev.16 [skip ci]
# app [1.26.0-dev.16](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.15...v1.26.0-dev.16) (2025-12-30)

### Features

* Show patches as individual steps in patcher screen ([#2889](https://github.com/ReVanced/revanced-manager/issues/2889)) ([11dd6e4](11dd6e4064))
2025-12-30 00:16:11 +00:00
Robert
11dd6e4064 feat: Show patches as individual steps in patcher screen (#2889)
Co-authored-by: Ax333l <main@axelen.xyz>
2025-12-30 01:08:54 +01:00
semantic-release-bot
35fb59b31d chore: Release v1.26.0-dev.15 [skip ci]
# app [1.26.0-dev.15](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.14...v1.26.0-dev.15) (2025-12-29)

### Bug Fixes

* install dialog getting stuck ([#2900](https://github.com/ReVanced/revanced-manager/issues/2900)) ([18a4df9](18a4df9af9))
2025-12-29 22:49:24 +00:00
Ax333l
18a4df9af9 fix: install dialog getting stuck (#2900) 2025-12-29 23:42:14 +01:00
semantic-release-bot
bd69b45a69 chore: Release v1.26.0-dev.14 [skip ci]
# app [1.26.0-dev.14](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.13...v1.26.0-dev.14) (2025-12-28)

### Bug Fixes

* Update selected patch count when SelectionState changes ([#2896](https://github.com/ReVanced/revanced-manager/issues/2896)) ([0d26df0](0d26df03f4))
2025-12-28 18:20:22 +00:00
Robert
0d26df03f4 fix: Update selected patch count when SelectionState changes (#2896) 2025-12-28 19:13:00 +01:00
semantic-release-bot
c436a7a100 chore: Release v1.26.0-dev.13 [skip ci]
# app [1.26.0-dev.13](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.12...v1.26.0-dev.13) (2025-12-17)

### Features

* Make patcher screen design more consistent with inspiration ([#2805](https://github.com/ReVanced/revanced-manager/issues/2805)) ([dbb6c01](dbb6c01e89))
2025-12-17 20:05:47 +00:00
Ushie
dbb6c01e89 feat: Make patcher screen design more consistent with inspiration (#2805) 2025-12-17 22:58:02 +03:00
semantic-release-bot
e0d529c2df chore: Release v1.26.0-dev.12 [skip ci]
# app [1.26.0-dev.12](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.11...v1.26.0-dev.12) (2025-12-17)

### Features

* Improve trust plugin dialog design ([#2420](https://github.com/ReVanced/revanced-manager/issues/2420)) ([0300da9](0300da9eac))
2025-12-17 16:04:25 +00:00
Ushie
0300da9eac feat: Improve trust plugin dialog design (#2420) 2025-12-17 18:54:04 +03:00
LisoUseInAIKyrios
4e5057ecad chore: Use raw string encoding to prevent AI translation errors (#2794)
Co-authored-by: Pun Butrach <pun.butrach@gmail.com>
2025-10-25 20:17:49 +07:00
Pun Butrach
1ef5c1c5c5 docs: Adjust feature request template to match other repository 2025-10-25 19:40:31 +07:00
Coby
39f52c1242 chore: Correct grammar mistakes (#2791)
Co-authored-by: Kobe <kobew5050@gmail.com>
2025-10-25 19:14:34 +07:00
65 changed files with 1512 additions and 975 deletions

View File

@@ -1,3 +1,7 @@
name: ⭐ Feature request
description: Create a detailed request for a new feature.
title: 'feat: '
labels: ['Feature request']
body: body:
- type: markdown - type: markdown
attributes: attributes:

43
.github/workflows/pull_strings.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Pull strings
on:
schedule:
- cron: "0 0 * * 0"
workflow_dispatch:
jobs:
pull:
name: Pull strings
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: dev
clean: true
- name: Pull strings
uses: crowdin/github-action@v2
with:
config: crowdin.yml
upload_sources: false
download_translations: true
skip_ref_checkout: true
localization_branch_name: feat/translations
create_pull_request: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Open pull request
if: github.event_name == 'workflow_dispatch'
uses: repo-sync/pull-request@v2
with:
source_branch: feat/translations
destination_branch: dev
pr_title: "chore: Sync translations"
pr_body: "Sync translations from [crowdin.com/project/revanced](https://crowdin.com/project/revanced)"

26
.github/workflows/push_strings.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Push strings
on:
workflow_dispatch:
push:
branches:
- dev
paths:
- app/src/main/res/values/strings.xml
jobs:
push:
name: Push strings
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Push strings
uses: crowdin/github-action@v2
with:
config: crowdin.yml
upload_sources: true
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -24,14 +24,6 @@ public final class app/revanced/manager/plugin/downloader/DownloadUrl : android/
public final fun writeToParcel (Landroid/os/Parcel;I)V public final fun writeToParcel (Landroid/os/Parcel;I)V
} }
public final class app/revanced/manager/plugin/downloader/DownloadUrl$Creator : android/os/Parcelable$Creator {
public fun <init> ()V
public final fun createFromParcel (Landroid/os/Parcel;)Lapp/revanced/manager/plugin/downloader/DownloadUrl;
public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object;
public final fun newArray (I)[Lapp/revanced/manager/plugin/downloader/DownloadUrl;
public synthetic fun newArray (I)[Ljava/lang/Object;
}
public final class app/revanced/manager/plugin/downloader/Downloader { public final class app/revanced/manager/plugin/downloader/Downloader {
public static final field $stable I public static final field $stable I
} }
@@ -85,14 +77,6 @@ public final class app/revanced/manager/plugin/downloader/Package : android/os/P
public final fun writeToParcel (Landroid/os/Parcel;I)V public final fun writeToParcel (Landroid/os/Parcel;I)V
} }
public final class app/revanced/manager/plugin/downloader/Package$Creator : android/os/Parcelable$Creator {
public fun <init> ()V
public final fun createFromParcel (Landroid/os/Parcel;)Lapp/revanced/manager/plugin/downloader/Package;
public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object;
public final fun newArray (I)[Lapp/revanced/manager/plugin/downloader/Package;
public synthetic fun newArray (I)[Ljava/lang/Object;
}
public abstract interface annotation class app/revanced/manager/plugin/downloader/PluginHostApi : java/lang/annotation/Annotation { public abstract interface annotation class app/revanced/manager/plugin/downloader/PluginHostApi : java/lang/annotation/Annotation {
} }
@@ -159,14 +143,6 @@ public abstract class app/revanced/manager/plugin/downloader/webview/IWebViewEve
public fun onTransact (ILandroid/os/Parcel;Landroid/os/Parcel;I)Z public fun onTransact (ILandroid/os/Parcel;Landroid/os/Parcel;I)Z
} }
public final class app/revanced/manager/plugin/downloader/webview/WebViewActivity$Parameters$Creator : android/os/Parcelable$Creator {
public fun <init> ()V
public final fun createFromParcel (Landroid/os/Parcel;)Lapp/revanced/manager/plugin/downloader/webview/WebViewActivity$Parameters;
public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object;
public final fun newArray (I)[Lapp/revanced/manager/plugin/downloader/webview/WebViewActivity$Parameters;
public synthetic fun newArray (I)[Ljava/lang/Object;
}
public abstract interface class app/revanced/manager/plugin/downloader/webview/WebViewCallbackScope : app/revanced/manager/plugin/downloader/Scope { public abstract interface class app/revanced/manager/plugin/downloader/webview/WebViewCallbackScope : app/revanced/manager/plugin/downloader/Scope {
public abstract fun finish (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun finish (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun load (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun load (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;

View File

@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.android.library) alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
@@ -17,9 +19,16 @@ dependencies {
implementation(libs.appcompat) implementation(libs.appcompat)
} }
kotlin {
jvmToolchain(17)
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}
android { android {
namespace = "app.revanced.manager.plugin.downloader" namespace = "app.revanced.manager.plugin.downloader"
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
minSdk = 26 minSdk = 26
@@ -42,10 +51,6 @@ android {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions {
jvmTarget = "17"
}
buildFeatures { buildFeatures {
aidl = true aidl = true
} }

View File

@@ -1,3 +1,71 @@
# app [1.26.0-dev.20](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.19...v1.26.0-dev.20) (2026-01-09)
### Bug Fixes
* Save FAB freaking out in select patches screen ([4c0b6b0](https://github.com/ReVanced/revanced-manager/commit/4c0b6b02e95a8d6f655bcf5c25493b1f9a4a4dcd))
# app [1.26.0-dev.19](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.18...v1.26.0-dev.19) (2026-01-08)
### Bug Fixes
* **locales:** use buildconfig instead of generating kt file ([72b1db9](https://github.com/ReVanced/revanced-manager/commit/72b1db9a2f33ab5d5fffd8ba83c05901eff19bea))
# app [1.26.0-dev.18](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.17...v1.26.0-dev.18) (2026-01-08)
### Bug Fixes
* Prevent trailing comma when no locales are generated ([b16931c](https://github.com/ReVanced/revanced-manager/commit/b16931ca79d5ce4d17c75f6dd3bf6f976b8ff7be))
### Features
* Add language settings ([#2913](https://github.com/ReVanced/revanced-manager/issues/2913)) ([df31b39](https://github.com/ReVanced/revanced-manager/commit/df31b39cc8c1fbf00bc3301468e8e7e4b283caf2))
# app [1.26.0-dev.17](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.16...v1.26.0-dev.17) (2026-01-06)
### Bug Fixes
* allow updating patches on metered networks ([9d9a0e8](https://github.com/ReVanced/revanced-manager/commit/9d9a0e81dbc9e73e6e3181f6bea9cabb69e49ea8))
# app [1.26.0-dev.16](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.15...v1.26.0-dev.16) (2025-12-30)
### Features
* Show patches as individual steps in patcher screen ([#2889](https://github.com/ReVanced/revanced-manager/issues/2889)) ([11dd6e4](https://github.com/ReVanced/revanced-manager/commit/11dd6e4064099427a8c9bc6f225a19412e5c70e2))
# app [1.26.0-dev.15](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.14...v1.26.0-dev.15) (2025-12-29)
### Bug Fixes
* install dialog getting stuck ([#2900](https://github.com/ReVanced/revanced-manager/issues/2900)) ([18a4df9](https://github.com/ReVanced/revanced-manager/commit/18a4df9af9cac120fdb8e4ff7aadd2e2a8d5c1a6))
# app [1.26.0-dev.14](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.13...v1.26.0-dev.14) (2025-12-28)
### Bug Fixes
* Update selected patch count when SelectionState changes ([#2896](https://github.com/ReVanced/revanced-manager/issues/2896)) ([0d26df0](https://github.com/ReVanced/revanced-manager/commit/0d26df03f463195dae550240c7f652680763079c))
# app [1.26.0-dev.13](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.12...v1.26.0-dev.13) (2025-12-17)
### Features
* Make patcher screen design more consistent with inspiration ([#2805](https://github.com/ReVanced/revanced-manager/issues/2805)) ([dbb6c01](https://github.com/ReVanced/revanced-manager/commit/dbb6c01e89a5e710185ff4304de0ac9e19bed053))
# app [1.26.0-dev.12](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.11...v1.26.0-dev.12) (2025-12-17)
### Features
* Improve trust plugin dialog design ([#2420](https://github.com/ReVanced/revanced-manager/issues/2420)) ([0300da9](https://github.com/ReVanced/revanced-manager/commit/0300da9eac6c0fc29dbbb66622c0d52f4cf68934))
# app [1.26.0-dev.11](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.10...v1.26.0-dev.11) (2025-10-25) # app [1.26.0-dev.11](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.10...v1.26.0-dev.11) (2025-10-25)

View File

@@ -1,4 +1,7 @@
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
import io.github.z4kn4fein.semver.toVersion import io.github.z4kn4fein.semver.toVersion
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import kotlin.random.Random import kotlin.random.Random
plugins { plugins {
@@ -9,6 +12,7 @@ plugins {
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
alias(libs.plugins.devtools) alias(libs.plugins.devtools)
alias(libs.plugins.about.libraries) alias(libs.plugins.about.libraries)
alias(libs.plugins.about.libraries.android)
signing signing
} }
@@ -81,7 +85,8 @@ dependencies {
implementation(libs.koin.workmanager) implementation(libs.koin.workmanager)
// Licenses // Licenses
implementation(libs.about.libraries) implementation(libs.about.libraries.core)
implementation(libs.about.libraries.m3)
// Ktor // Ktor
implementation(libs.ktor.core) implementation(libs.ktor.core)
@@ -108,6 +113,10 @@ dependencies {
// Compose Icons // Compose Icons
implementation(libs.compose.icons.fontawesome) implementation(libs.compose.icons.fontawesome)
// Ackpine
implementation(libs.ackpine.core)
implementation(libs.ackpine.ktx)
} }
buildscript { buildscript {
@@ -122,7 +131,7 @@ buildscript {
android { android {
namespace = "app.revanced.manager" namespace = "app.revanced.manager"
compileSdk = 35 compileSdk = 36
buildToolsVersion = "35.0.1" buildToolsVersion = "35.0.1"
defaultConfig { defaultConfig {
@@ -139,13 +148,25 @@ android {
(preRelease?.substringAfterLast('.')?.toInt() ?: 99) (preRelease?.substringAfterLast('.')?.toInt() ?: 99)
} }
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
val resDir = file("src/main/res")
val locales = resDir.listFiles()
.orEmpty()
//noinspection WrongGradleMethod
.filter { it.isDirectory && it.name.matches(Regex("values-[a-z]{2}(-r[A-Z]{2})?")) }
//noinspection WrongGradleMethod
.map { it.name.removePrefix("values-").replace("-r", "-") }
.sorted()
//noinspection WrongGradleMethod
.joinToString(prefix = "{", separator = ",", postfix = "}") { "\"$it\"" }
buildConfigField("String[]", "SUPPORTED_LOCALES", locales)
} }
buildTypes { buildTypes {
debug { debug {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
resValue("string", "app_name", "ReVanced Manager (Debug)") resValue("string", "app_name", "ReVanced Manager (Debug)")
isPseudoLocalesEnabled = true
buildConfigField("long", "BUILD_ID", "${Random.nextLong()}L") buildConfigField("long", "BUILD_ID", "${Random.nextLong()}L")
} }
@@ -217,20 +238,14 @@ android {
arg("room.schemaLocation", "$projectDir/schemas") arg("room.schemaLocation", "$projectDir/schemas")
} }
kotlinOptions {
jvmTarget = "17"
}
buildFeatures { buildFeatures {
compose = true compose = true
aidl = true aidl = true
buildConfig = true buildConfig = true
} }
android { androidResources {
androidResources { generateLocaleConfig = true
generateLocaleConfig = true
}
} }
externalNativeBuild { externalNativeBuild {
@@ -243,6 +258,18 @@ android {
kotlin { kotlin {
jvmToolchain(17) jvmToolchain(17)
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}
aboutLibraries {
library {
// Enable the duplication mode, allows to merge, or link dependencies which relate
duplicationMode = DuplicateMode.MERGE
// Configure the duplication rule, to match "duplicates" with
duplicationRule = DuplicateRule.EXACT
}
} }
tasks { tasks {

View File

@@ -1 +1 @@
version = 1.26.0-dev.11 version = 1.26.0-dev.20

View File

@@ -51,9 +51,6 @@
<activity android:name=".plugin.downloader.webview.WebViewActivity" android:exported="false" android:theme="@style/Theme.WebViewActivity" /> <activity android:name=".plugin.downloader.webview.WebViewActivity" android:exported="false" android:theme="@style/Theme.WebViewActivity" />
<service android:name=".service.InstallService" />
<service android:name=".service.UninstallService" />
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="specialUse" android:foregroundServiceType="specialUse"
@@ -75,5 +72,15 @@
android:value="androidx.startup" android:value="androidx.startup"
tools:node="remove" /> tools:node="remove" />
</provider> </provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="ru.solrudev.ackpine.AckpineInitializer"
tools:node="remove" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@@ -0,0 +1,4 @@
// ProgressEventParcel.aidl
package app.revanced.manager.patcher;
parcelable ProgressEventParcel;

View File

@@ -1,11 +1,12 @@
// IPatcherEvents.aidl // IPatcherEvents.aidl
package app.revanced.manager.patcher.runtime.process; package app.revanced.manager.patcher.runtime.process;
import app.revanced.manager.patcher.ProgressEventParcel;
// Interface for sending events back to the main app process. // Interface for sending events back to the main app process.
oneway interface IPatcherEvents { oneway interface IPatcherEvents {
void log(String level, String msg); void log(String level, String msg);
void patchSucceeded(); void event(in ProgressEventParcel event);
void progress(String name, String state, String msg);
// The patching process has ended. The exceptionStackTrace is null if it finished successfully. // The patching process has ended. The exceptionStackTrace is null if it finished successfully.
void finished(String exceptionStackTrace); void finished(String exceptionStackTrace);
} }

View File

@@ -3,11 +3,11 @@ package app.revanced.manager
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
@@ -59,11 +59,10 @@ import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
import app.revanced.manager.util.EventEffect import app.revanced.manager.util.EventEffect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.compose.navigation.koinNavViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel
class MainActivity : ComponentActivity() { class MainActivity : AppCompatActivity() {
@ExperimentalAnimationApi @ExperimentalAnimationApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -185,7 +184,7 @@ private fun ReVancedManager(vm: MainViewModel) {
val data = val data =
parentBackStackEntry.getComplexArg<SelectedApplicationInfo.ViewModelParams>() parentBackStackEntry.getComplexArg<SelectedApplicationInfo.ViewModelParams>()
val viewModel = val viewModel =
koinNavViewModel<SelectedAppInfoViewModel>(viewModelStoreOwner = parentBackStackEntry) { koinViewModel<SelectedAppInfoViewModel>(viewModelStoreOwner = parentBackStackEntry) {
parametersOf(data) parametersOf(data)
} }
@@ -226,7 +225,7 @@ private fun ReVancedManager(vm: MainViewModel) {
composable<SelectedApplicationInfo.PatchesSelector> { composable<SelectedApplicationInfo.PatchesSelector> {
val data = val data =
it.getComplexArg<SelectedApplicationInfo.PatchesSelector.ViewModelParams>() it.getComplexArg<SelectedApplicationInfo.PatchesSelector.ViewModelParams>()
val selectedAppInfoVm = koinNavViewModel<SelectedAppInfoViewModel>( val selectedAppInfoVm = koinViewModel<SelectedAppInfoViewModel>(
viewModelStoreOwner = navController.navGraphEntry(it) viewModelStoreOwner = navController.navGraphEntry(it)
) )
@@ -243,7 +242,7 @@ private fun ReVancedManager(vm: MainViewModel) {
composable<SelectedApplicationInfo.RequiredOptions> { composable<SelectedApplicationInfo.RequiredOptions> {
val data = val data =
it.getComplexArg<SelectedApplicationInfo.PatchesSelector.ViewModelParams>() it.getComplexArg<SelectedApplicationInfo.PatchesSelector.ViewModelParams>()
val selectedAppInfoVm = koinNavViewModel<SelectedAppInfoViewModel>( val selectedAppInfoVm = koinViewModel<SelectedAppInfoViewModel>(
viewModelStoreOwner = navController.navGraphEntry(it) viewModelStoreOwner = navController.navGraphEntry(it)
) )

View File

@@ -48,7 +48,8 @@ class ManagerApplication : Application() {
workerModule, workerModule,
viewModelModule, viewModelModule,
databaseModule, databaseModule,
rootModule rootModule,
ackpineModule
) )
} }

View File

@@ -15,5 +15,5 @@ class NetworkInfo(app: Application) {
/** /**
* Returns true if it is safe to download large files. * Returns true if it is safe to download large files.
*/ */
fun isSafe() = isConnected() && isUnmetered() fun isSafe(ignoreMetered: Boolean) = isConnected() && (ignoreMetered || isUnmetered())
} }

View File

@@ -0,0 +1,19 @@
package app.revanced.manager.di
import android.content.Context
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
import ru.solrudev.ackpine.installer.PackageInstaller
import ru.solrudev.ackpine.uninstaller.PackageUninstaller
val ackpineModule = module {
fun provideInstaller(context: Context) = PackageInstaller.getInstance(context)
fun provideUninstaller(context: Context) = PackageUninstaller.getInstance(context)
single {
provideInstaller(androidContext())
}
single {
provideUninstaller(androidContext())
}
}

View File

@@ -1,7 +1,7 @@
package app.revanced.manager.di package app.revanced.manager.di
import app.revanced.manager.ui.viewmodel.* import app.revanced.manager.ui.viewmodel.*
import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.core.module.dsl.*
import org.koin.dsl.module import org.koin.dsl.module
val viewModelModule = module { val viewModelModule = module {

View File

@@ -34,4 +34,6 @@ class PreferencesManager(
val acknowledgedDownloaderPlugins = stringSetPreference("acknowledged_downloader_plugins", emptySet()) val acknowledgedDownloaderPlugins = stringSetPreference("acknowledged_downloader_plugins", emptySet())
val showDeveloperSettings = booleanPreference("show_developer_settings", context.isDebuggable) val showDeveloperSettings = booleanPreference("show_developer_settings", context.isDebuggable)
val allowMeteredNetworks = booleanPreference("allow_metered_networks", false)
} }

View File

@@ -286,28 +286,29 @@ class PatchBundleRepository(
State(sources.toPersistentMap(), info.toPersistentMap()) State(sources.toPersistentMap(), info.toPersistentMap())
} }
suspend fun createLocal(createStream: suspend () -> InputStream) = dispatchAction("Add bundle") { suspend fun createLocal(createStream: suspend () -> InputStream) =
with(createEntity("", SourceInfo.Local).load() as LocalPatchBundle) { dispatchAction("Add bundle") {
try { with(createEntity("", SourceInfo.Local).load() as LocalPatchBundle) {
createStream().use { patches -> replace(patches) } try {
} catch (e: Exception) { createStream().use { patches -> replace(patches) }
if (e is CancellationException) throw e } catch (e: Exception) {
Log.e(tag, "Got exception while importing bundle", e) if (e is CancellationException) throw e
withContext(Dispatchers.Main) { Log.e(tag, "Got exception while importing bundle", e)
app.toast(app.getString(R.string.patches_replace_fail, e.simpleMessage())) withContext(Dispatchers.Main) {
app.toast(app.getString(R.string.patches_replace_fail, e.simpleMessage()))
}
deleteLocalFile()
} }
deleteLocalFile()
} }
}
doReload() doReload()
} }
suspend fun createRemote(url: String, autoUpdate: Boolean) = suspend fun createRemote(url: String, autoUpdate: Boolean) =
dispatchAction("Add bundle ($url)") { state -> dispatchAction("Add bundle ($url)") { state ->
val src = createEntity("", SourceInfo.from(url), autoUpdate).load() as RemotePatchBundle val src = createEntity("", SourceInfo.from(url), autoUpdate).load() as RemotePatchBundle
update(src) update(src, force = true)
state.copy(sources = state.sources.put(src.uid, src)) state.copy(sources = state.sources.put(src.uid, src))
} }
@@ -329,32 +330,38 @@ class PatchBundleRepository(
state.copy(sources = state.sources.put(uid, newSrc)) state.copy(sources = state.sources.put(uid, newSrc))
} }
suspend fun update(vararg sources: RemotePatchBundle, showToast: Boolean = false) { suspend fun update(
vararg sources: RemotePatchBundle,
showToast: Boolean = false,
force: Boolean = false
) {
val uids = sources.map { it.uid }.toSet() val uids = sources.map { it.uid }.toSet()
store.dispatch(Update(showToast = showToast) { it.uid in uids }) store.dispatch(Update(showToast = showToast, force = force) { it.uid in uids })
} }
suspend fun redownloadRemoteBundles() = store.dispatch(Update(force = true)) suspend fun redownloadRemoteBundles() = store.dispatch(Update(force = true, redownload = true))
/** /**
* Updates all bundles that should be automatically updated. * Updates all bundles that should be automatically updated.
*/ */
suspend fun updateCheck() = store.dispatch(Update { it.autoUpdate }) suspend fun updateCheck() =
store.dispatch(Update(force = prefs.allowMeteredNetworks.get()) { it.autoUpdate })
private inner class Update( private inner class Update(
private val force: Boolean = false, private val force: Boolean = false,
private val redownload: Boolean = false,
private val showToast: Boolean = false, private val showToast: Boolean = false,
private val predicate: (bundle: RemotePatchBundle) -> Boolean = { true }, private val predicate: (bundle: RemotePatchBundle) -> Boolean = { true },
) : Action<State> { ) : Action<State> {
private suspend fun toast(@StringRes id: Int, vararg args: Any?) = private suspend fun toast(@StringRes id: Int, vararg args: Any?) =
withContext(Dispatchers.Main) { app.toast(app.getString(id, *args)) } withContext(Dispatchers.Main) { app.toast(app.getString(id, *args)) }
override fun toString() = if (force) "Redownload remote bundles" else "Update check" override fun toString() = if (redownload) "Redownload remote bundles" else "Update check"
override suspend fun ActionContext.execute( override suspend fun ActionContext.execute(
current: State current: State
) = coroutineScope { ) = coroutineScope {
if (!networkInfo.isSafe()) { if (!networkInfo.isSafe(force)) {
Log.d(tag, "Skipping update check because the network is down or metered.") Log.d(tag, "Skipping update check because the network is down or metered.")
return@coroutineScope current return@coroutineScope current
} }
@@ -367,7 +374,7 @@ class PatchBundleRepository(
Log.d(tag, "Updating patch bundle: ${it.name}") Log.d(tag, "Updating patch bundle: ${it.name}")
val newVersion = with(it) { val newVersion = with(it) {
if (force) downloadLatest() else update() if (redownload) downloadLatest() else update()
} ?: return@async null } ?: return@async null
it to newVersion it to newVersion

View File

@@ -16,8 +16,11 @@ import io.ktor.http.isSuccess
import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.core.isNotEmpty import io.ktor.utils.io.core.isNotEmpty
import io.ktor.utils.io.core.readBytes import io.ktor.utils.io.core.readBytes
import io.ktor.utils.io.exhausted
import io.ktor.utils.io.readRemaining
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.io.asSink
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File import java.io.File
import java.io.OutputStream import java.io.OutputStream
@@ -69,14 +72,12 @@ class HttpService(
) { ) {
http.prepareGet(builder).execute { httpResponse -> http.prepareGet(builder).execute { httpResponse ->
if (httpResponse.status.isSuccess()) { if (httpResponse.status.isSuccess()) {
val channel: ByteReadChannel = httpResponse.body()
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
while (!channel.isClosedForRead) { val channel: ByteReadChannel = httpResponse.body()
val sink = outputStream.asSink()
while (!channel.exhausted()) {
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong()) val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
while (packet.isNotEmpty) { packet.transferTo(sink)
val bytes = packet.readBytes()
outputStream.write(bytes)
}
} }
} }

View File

@@ -0,0 +1,78 @@
package app.revanced.manager.patcher
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
sealed class ProgressEvent : Parcelable {
abstract val stepId: StepId?
data class Started(override val stepId: StepId) : ProgressEvent()
data class Progress(
override val stepId: StepId,
val current: Long? = null,
val total: Long? = null,
val message: String? = null,
) : ProgressEvent()
data class Completed(
override val stepId: StepId,
) : ProgressEvent()
data class Failed(
override val stepId: StepId?,
val error: RemoteError,
) : ProgressEvent()
}
/**
* Parcelable wrapper for [ProgressEvent].
*
* Required because AIDL does not support sealed classes.
*/
@Parcelize
data class ProgressEventParcel(val event: ProgressEvent) : Parcelable
fun ProgressEventParcel.toEvent(): ProgressEvent = event
fun ProgressEvent.toParcel(): ProgressEventParcel = ProgressEventParcel(this)
@Parcelize
sealed class StepId : Parcelable {
data object DownloadAPK : StepId()
data object LoadPatches : StepId()
data object ReadAPK : StepId()
data object ExecutePatches : StepId()
data class ExecutePatch(val index: Int) : StepId()
data object WriteAPK : StepId()
data object SignAPK : StepId()
}
@Parcelize
data class RemoteError(
val type: String,
val message: String?,
val stackTrace: String,
) : Parcelable
fun Exception.toRemoteError() = RemoteError(
type = this::class.java.name,
message = this.message,
stackTrace = this.stackTraceToString(),
)
inline fun <T> runStep(
stepId: StepId,
onEvent: (ProgressEvent) -> Unit,
block: () -> T,
): T = try {
onEvent(ProgressEvent.Started(stepId))
val value = block()
onEvent(ProgressEvent.Completed(stepId))
value
} catch (error: Exception) {
onEvent(ProgressEvent.Failed(stepId, error.toRemoteError()))
throw error
}

View File

@@ -1,10 +1,9 @@
package app.revanced.manager.patcher package app.revanced.manager.patcher
import android.content.Context
import app.revanced.library.ApkUtils.applyTo import app.revanced.library.ApkUtils.applyTo
import app.revanced.manager.R import app.revanced.manager.patcher.Session.Companion.component1
import app.revanced.manager.patcher.Session.Companion.component2
import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.ui.model.State
import app.revanced.patcher.Patcher import app.revanced.patcher.Patcher
import app.revanced.patcher.PatcherConfig import app.revanced.patcher.PatcherConfig
import app.revanced.patcher.patch.Patch import app.revanced.patcher.patch.Patch
@@ -22,15 +21,10 @@ class Session(
cacheDir: String, cacheDir: String,
frameworkDir: String, frameworkDir: String,
aaptPath: String, aaptPath: String,
private val androidContext: Context,
private val logger: Logger, private val logger: Logger,
private val input: File, private val input: File,
private val onPatchCompleted: suspend () -> Unit, private val onEvent: (ProgressEvent) -> Unit,
private val onProgress: (name: String?, state: State?, message: String?) -> Unit
) : Closeable { ) : Closeable {
private fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =
onProgress(name, state, message)
private val tempDir = File(cacheDir).resolve("patcher").also { it.mkdirs() } private val tempDir = File(cacheDir).resolve("patcher").also { it.mkdirs() }
private val patcher = Patcher( private val patcher = Patcher(
PatcherConfig( PatcherConfig(
@@ -42,86 +36,68 @@ class Session(
) )
private suspend fun Patcher.applyPatchesVerbose(selectedPatches: PatchList) { private suspend fun Patcher.applyPatchesVerbose(selectedPatches: PatchList) {
var nextPatchIndex = 0
updateProgress(
name = androidContext.getString(R.string.executing_patch, selectedPatches[nextPatchIndex]),
state = State.RUNNING
)
this().collect { (patch, exception) -> this().collect { (patch, exception) ->
if (patch !in selectedPatches) return@collect val index = selectedPatches.indexOf(patch)
if (index == -1) return@collect
if (exception != null) { if (exception != null) {
updateProgress( onEvent(
name = androidContext.getString(R.string.failed_to_execute_patch, patch.name), ProgressEvent.Failed(
state = State.FAILED, StepId.ExecutePatch(index),
message = exception.stackTraceToString() exception.toRemoteError(),
)
) )
logger.error("${patch.name} failed:") logger.error("${patch.name} failed:")
logger.error(exception.stackTraceToString()) logger.error(exception.stackTraceToString())
throw exception throw exception
} }
nextPatchIndex++ onEvent(
ProgressEvent.Completed(
onPatchCompleted() StepId.ExecutePatch(index),
selectedPatches.getOrNull(nextPatchIndex)?.let { nextPatch ->
updateProgress(
name = androidContext.getString(R.string.executing_patch, nextPatch.name)
) )
} )
logger.info("${patch.name} succeeded") logger.info("${patch.name} succeeded")
} }
updateProgress(
state = State.COMPLETED,
name = androidContext.resources.getQuantityString(
R.plurals.patches_executed,
selectedPatches.size,
selectedPatches.size
)
)
} }
suspend fun run(output: File, selectedPatches: PatchList) { suspend fun run(output: File, selectedPatches: PatchList) {
updateProgress(state = State.COMPLETED) // Unpacking runStep(StepId.ExecutePatches, onEvent) {
java.util.logging.Logger.getLogger("").apply {
handlers.forEach {
it.close()
removeHandler(it)
}
java.util.logging.Logger.getLogger("").apply { addHandler(logger.handler)
handlers.forEach {
it.close()
removeHandler(it)
} }
addHandler(logger.handler) with(patcher) {
logger.info("Merging integrations")
this += selectedPatches.toSet()
logger.info("Applying patches...")
applyPatchesVerbose(selectedPatches.sortedBy { it.name })
}
} }
with(patcher) { runStep(StepId.WriteAPK, onEvent) {
logger.info("Merging integrations") logger.info("Writing patched files...")
this += selectedPatches.toSet() val result = patcher.get()
logger.info("Applying patches...") val patched = tempDir.resolve("result.apk")
applyPatchesVerbose(selectedPatches.sortedBy { it.name }) withContext(Dispatchers.IO) {
Files.copy(input.toPath(), patched.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
result.applyTo(patched)
logger.info("Patched apk saved to $patched")
withContext(Dispatchers.IO) {
Files.move(patched.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
} }
logger.info("Writing patched files...")
val result = patcher.get()
val patched = tempDir.resolve("result.apk")
withContext(Dispatchers.IO) {
Files.copy(input.toPath(), patched.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
result.applyTo(patched)
logger.info("Patched apk saved to $patched")
withContext(Dispatchers.IO) {
Files.move(patched.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
updateProgress(state = State.COMPLETED) // Saving
} }
override fun close() { override fun close() {

View File

@@ -1,11 +1,12 @@
package app.revanced.manager.patcher.runtime package app.revanced.manager.patcher.runtime
import android.content.Context import android.content.Context
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.Session import app.revanced.manager.patcher.Session
import app.revanced.manager.patcher.StepId
import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.patch.PatchBundle import app.revanced.manager.patcher.patch.PatchBundle
import app.revanced.manager.patcher.worker.ProgressEventHandler import app.revanced.manager.patcher.runStep
import app.revanced.manager.ui.model.State
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.PatchSelection
import java.io.File import java.io.File
@@ -13,7 +14,7 @@ import java.io.File
/** /**
* Simple [Runtime] implementation that runs the patcher using coroutines. * Simple [Runtime] implementation that runs the patcher using coroutines.
*/ */
class CoroutineRuntime(private val context: Context) : Runtime(context) { class CoroutineRuntime(context: Context) : Runtime(context) {
override suspend fun execute( override suspend fun execute(
inputFile: String, inputFile: String,
outputFile: String, outputFile: String,
@@ -21,47 +22,50 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) {
selectedPatches: PatchSelection, selectedPatches: PatchSelection,
options: Options, options: Options,
logger: Logger, logger: Logger,
onPatchCompleted: suspend () -> Unit, onEvent: (ProgressEvent) -> Unit,
onProgress: ProgressEventHandler,
) { ) {
val selectedBundles = selectedPatches.keys val patchList = runStep(StepId.LoadPatches, onEvent) {
val bundles = bundles() val selectedBundles = selectedPatches.keys
val uids = bundles.entries.associate { (key, value) -> value to key } val bundles = bundles()
val uids = bundles.entries.associate { (key, value) -> value to key }
val allPatches = val allPatches =
PatchBundle.Loader.patches(bundles.values, packageName) PatchBundle.Loader.patches(bundles.values, packageName)
.mapKeys { (b, _) -> uids[b]!! } .mapKeys { (b, _) -> uids[b]!! }
.filterKeys { it in selectedBundles } .filterKeys { it in selectedBundles }
val patchList = selectedPatches.flatMap { (bundle, selected) -> val patchList = selectedPatches.flatMap { (bundle, selected) ->
allPatches[bundle]?.filter { it.name in selected } allPatches[bundle]?.filter { it.name in selected }
?: throw IllegalArgumentException("Patch bundle $bundle does not exist") ?: throw IllegalArgumentException("Patch bundle $bundle does not exist")
} }
// Set all patch options. // Set all patch options.
options.forEach { (bundle, bundlePatchOptions) -> options.forEach { (bundle, bundlePatchOptions) ->
val patches = allPatches[bundle] ?: return@forEach val patches = allPatches[bundle] ?: return@forEach
bundlePatchOptions.forEach { (patchName, configuredPatchOptions) -> bundlePatchOptions.forEach { (patchName, configuredPatchOptions) ->
val patchOptions = patches.single { it.name == patchName }.options val patchOptions = patches.single { it.name == patchName }.options
configuredPatchOptions.forEach { (key, value) -> configuredPatchOptions.forEach { (key, value) ->
patchOptions[key] = value patchOptions[key] = value
}
} }
} }
patchList
} }
onProgress(null, State.COMPLETED, null) // Loading patches val session = runStep(StepId.ReadAPK, onEvent) {
Session(
cacheDir,
frameworkPath,
aaptPath,
logger,
File(inputFile),
onEvent,
)
}
Session( session.use { s ->
cacheDir, s.run(
frameworkPath,
aaptPath,
context,
logger,
File(inputFile),
onPatchCompleted = onPatchCompleted,
onProgress
).use { session ->
session.run(
File(outputFile), File(outputFile),
patchList patchList
) )

View File

@@ -10,12 +10,13 @@ import app.revanced.manager.BuildConfig
import app.revanced.manager.patcher.runtime.process.IPatcherEvents import app.revanced.manager.patcher.runtime.process.IPatcherEvents
import app.revanced.manager.patcher.runtime.process.IPatcherProcess import app.revanced.manager.patcher.runtime.process.IPatcherProcess
import app.revanced.manager.patcher.LibraryResolver import app.revanced.manager.patcher.LibraryResolver
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.ProgressEventParcel
import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.runtime.process.Parameters import app.revanced.manager.patcher.runtime.process.Parameters
import app.revanced.manager.patcher.runtime.process.PatchConfiguration import app.revanced.manager.patcher.runtime.process.PatchConfiguration
import app.revanced.manager.patcher.runtime.process.PatcherProcess import app.revanced.manager.patcher.runtime.process.PatcherProcess
import app.revanced.manager.patcher.worker.ProgressEventHandler import app.revanced.manager.patcher.toEvent
import app.revanced.manager.ui.model.State
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.PatchSelection
@@ -66,8 +67,7 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
selectedPatches: PatchSelection, selectedPatches: PatchSelection,
options: Options, options: Options,
logger: Logger, logger: Logger,
onPatchCompleted: suspend () -> Unit, onEvent: (ProgressEvent) -> Unit,
onProgress: ProgressEventHandler,
) = coroutineScope { ) = coroutineScope {
// Get the location of our own Apk. // Get the location of our own Apk.
val managerBaseApk = pm.getPackageInfo(context.packageName)!!.applicationInfo!!.sourceDir val managerBaseApk = pm.getPackageInfo(context.packageName)!!.applicationInfo!!.sourceDir
@@ -111,7 +111,6 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
} }
val patching = CompletableDeferred<Unit>() val patching = CompletableDeferred<Unit>()
val scope = this
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
val binder = awaitBinderConnection() val binder = awaitBinderConnection()
@@ -124,13 +123,10 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
val eventHandler = object : IPatcherEvents.Stub() { val eventHandler = object : IPatcherEvents.Stub() {
override fun log(level: String, msg: String) = logger.log(enumValueOf(level), msg) override fun log(level: String, msg: String) = logger.log(enumValueOf(level), msg)
override fun patchSucceeded() { override fun event(event: ProgressEventParcel?) {
scope.launch { onPatchCompleted() } event?.let { onEvent(it.toEvent()) }
} }
override fun progress(name: String?, state: String?, msg: String?) =
onProgress(name, state?.let { enumValueOf<State>(it) }, msg)
override fun finished(exceptionStackTrace: String?) { override fun finished(exceptionStackTrace: String?) {
binder.exit() binder.exit()

View File

@@ -4,9 +4,9 @@ import android.content.Context
import app.revanced.manager.data.platform.Filesystem import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.aapt.Aapt import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.worker.ProgressEventHandler
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.PatchSelection
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -34,7 +34,6 @@ sealed class Runtime(context: Context) : KoinComponent {
selectedPatches: PatchSelection, selectedPatches: PatchSelection,
options: Options, options: Options,
logger: Logger, logger: Logger,
onPatchCompleted: suspend () -> Unit, onEvent: (ProgressEvent) -> Unit,
onProgress: ProgressEventHandler,
) )
} }

View File

@@ -8,12 +8,15 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Looper import android.os.Looper
import app.revanced.manager.BuildConfig import app.revanced.manager.BuildConfig
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.Session import app.revanced.manager.patcher.Session
import app.revanced.manager.patcher.StepId
import app.revanced.manager.patcher.logger.LogLevel import app.revanced.manager.patcher.logger.LogLevel
import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.patch.PatchBundle import app.revanced.manager.patcher.patch.PatchBundle
import app.revanced.manager.patcher.runStep
import app.revanced.manager.patcher.runtime.ProcessRuntime import app.revanced.manager.patcher.runtime.ProcessRuntime
import app.revanced.manager.ui.model.State import app.revanced.manager.patcher.toParcel
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -24,7 +27,7 @@ import kotlin.system.exitProcess
/** /**
* The main class that runs inside the runner process launched by [ProcessRuntime]. * The main class that runs inside the runner process launched by [ProcessRuntime].
*/ */
class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() { class PatcherProcess() : IPatcherProcess.Stub() {
private var eventBinder: IPatcherEvents? = null private var eventBinder: IPatcherEvents? = null
private val scope = private val scope =
@@ -46,6 +49,8 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
override fun exit() = exitProcess(0) override fun exit() = exitProcess(0)
override fun start(parameters: Parameters, events: IPatcherEvents) { override fun start(parameters: Parameters, events: IPatcherEvents) {
fun onEvent(event: ProgressEvent) = events.event(event.toParcel())
eventBinder = events eventBinder = events
scope.launch { scope.launch {
@@ -56,38 +61,42 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
logger.info("Memory limit: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB") logger.info("Memory limit: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB")
val allPatches = PatchBundle.Loader.patches(parameters.configurations.map { it.bundle }, parameters.packageName) val patchList = runStep(StepId.LoadPatches, ::onEvent) {
val patchList = parameters.configurations.flatMap { config -> val allPatches = PatchBundle.Loader.patches(
val patches = (allPatches[config.bundle] ?: return@flatMap emptyList()) parameters.configurations.map { it.bundle },
parameters.packageName
)
parameters.configurations.flatMap { config ->
val patches = (allPatches[config.bundle] ?: return@flatMap emptyList())
.filter { it.name in config.patches } .filter { it.name in config.patches }
.associateBy { it.name } .associateBy { it.name }
config.options.forEach { (patchName, opts) -> config.options.forEach { (patchName, opts) ->
val patchOptions = patches[patchName]?.options val patchOptions = patches[patchName]?.options
?: throw Exception("Patch with name $patchName does not exist.") ?: throw Exception("Patch with name $patchName does not exist.")
opts.forEach { (key, value) -> opts.forEach { (key, value) ->
patchOptions[key] = value patchOptions[key] = value
}
} }
}
patches.values patches.values
}
} }
events.progress(null, State.COMPLETED.name, null) // Loading patches val session = runStep(StepId.ReadAPK, ::onEvent) {
Session(
cacheDir = parameters.cacheDir,
aaptPath = parameters.aaptPath,
frameworkDir = parameters.frameworkDir,
logger = logger,
input = File(parameters.inputFile),
onEvent = ::onEvent,
)
}
Session( session.use {
cacheDir = parameters.cacheDir,
aaptPath = parameters.aaptPath,
frameworkDir = parameters.frameworkDir,
androidContext = context,
logger = logger,
input = File(parameters.inputFile),
onPatchCompleted = { events.patchSucceeded() },
onProgress = { name, state, message ->
events.progress(name, state?.name, message)
}
).use {
it.run(File(parameters.outputFile), patchList) it.run(File(parameters.outputFile), patchList)
} }
@@ -119,7 +128,7 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
} }
} }
val ipcInterface = PatcherProcess(appContext) val ipcInterface = PatcherProcess()
appContext.sendBroadcast(Intent().apply { appContext.sendBroadcast(Intent().apply {
action = ProcessRuntime.CONNECT_TO_APP_ACTION action = ProcessRuntime.CONNECT_TO_APP_ACTION

View File

@@ -29,14 +29,17 @@ import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.worker.Worker import app.revanced.manager.domain.worker.Worker
import app.revanced.manager.domain.worker.WorkerRepository import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.StepId
import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.runStep
import app.revanced.manager.patcher.runtime.CoroutineRuntime import app.revanced.manager.patcher.runtime.CoroutineRuntime
import app.revanced.manager.patcher.runtime.ProcessRuntime import app.revanced.manager.patcher.runtime.ProcessRuntime
import app.revanced.manager.patcher.toRemoteError
import app.revanced.manager.plugin.downloader.GetScope import app.revanced.manager.plugin.downloader.GetScope
import app.revanced.manager.plugin.downloader.PluginHostApi import app.revanced.manager.plugin.downloader.PluginHostApi
import app.revanced.manager.plugin.downloader.UserInteractionException import app.revanced.manager.plugin.downloader.UserInteractionException
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.State
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.PatchSelection
@@ -48,8 +51,6 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import java.io.File import java.io.File
typealias ProgressEventHandler = (name: String?, state: State?, message: String?) -> Unit
@OptIn(PluginHostApi::class) @OptIn(PluginHostApi::class)
class PatcherWorker( class PatcherWorker(
context: Context, context: Context,
@@ -71,11 +72,9 @@ class PatcherWorker(
val selectedPatches: PatchSelection, val selectedPatches: PatchSelection,
val options: Options, val options: Options,
val logger: Logger, val logger: Logger,
val onDownloadProgress: suspend (Pair<Long, Long?>?) -> Unit,
val onPatchCompleted: suspend () -> Unit,
val handleStartActivityRequest: suspend (LoadedDownloaderPlugin, Intent) -> ActivityResult, val handleStartActivityRequest: suspend (LoadedDownloaderPlugin, Intent) -> ActivityResult,
val setInputFile: suspend (File) -> Unit, val setInputFile: suspend (File) -> Unit,
val onProgress: ProgressEventHandler val onEvent: (ProgressEvent) -> Unit,
) { ) {
val packageName get() = input.packageName val packageName get() = input.packageName
} }
@@ -140,10 +139,6 @@ class PatcherWorker(
} }
private suspend fun runPatcher(args: Args): Result { private suspend fun runPatcher(args: Args): Result {
fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =
args.onProgress(name, state, message)
val patchedApk = fs.tempDir.resolve("patched.apk") val patchedApk = fs.tempDir.resolve("patched.apk")
return try { return try {
@@ -163,51 +158,65 @@ class PatcherWorker(
args.input.version, args.input.version,
prefs.suggestedVersionSafeguard.get(), prefs.suggestedVersionSafeguard.get(),
!prefs.disablePatchVersionCompatCheck.get(), !prefs.disablePatchVersionCompatCheck.get(),
onDownload = args.onDownloadProgress onDownload = { progress ->
).also { args.onEvent(
args.setInputFile(it) ProgressEvent.Progress(
updateProgress(state = State.COMPLETED) // Download APK stepId = StepId.DownloadAPK,
} current = progress.first,
total = progress.second
)
)
}
).also { args.setInputFile(it) }
val inputFile = when (val selectedApp = args.input) { val inputFile = when (val selectedApp = args.input) {
is SelectedApp.Download -> { is SelectedApp.Download -> {
val (plugin, data) = downloaderPluginRepository.unwrapParceledData(selectedApp.data) runStep(StepId.DownloadAPK, args.onEvent) {
val (plugin, data) = downloaderPluginRepository.unwrapParceledData(
selectedApp.data
)
download(plugin, data) download(plugin, data)
}
} }
is SelectedApp.Search -> { is SelectedApp.Search -> {
downloaderPluginRepository.loadedPluginsFlow.first() runStep(StepId.DownloadAPK, args.onEvent) {
.firstNotNullOfOrNull { plugin -> downloaderPluginRepository.loadedPluginsFlow.first()
try { .firstNotNullOfOrNull { plugin ->
val getScope = object : GetScope { try {
override val pluginPackageName = plugin.packageName val getScope = object : GetScope {
override val hostPackageName = applicationContext.packageName override val pluginPackageName = plugin.packageName
override suspend fun requestStartActivity(intent: Intent): Intent? { override val hostPackageName =
val result = args.handleStartActivityRequest(plugin, intent) applicationContext.packageName
return when (result.resultCode) {
Activity.RESULT_OK -> result.data override suspend fun requestStartActivity(intent: Intent): Intent? {
Activity.RESULT_CANCELED -> throw UserInteractionException.Activity.Cancelled() val result =
else -> throw UserInteractionException.Activity.NotCompleted( args.handleStartActivityRequest(plugin, intent)
result.resultCode, return when (result.resultCode) {
result.data Activity.RESULT_OK -> result.data
) Activity.RESULT_CANCELED -> throw UserInteractionException.Activity.Cancelled()
else -> throw UserInteractionException.Activity.NotCompleted(
result.resultCode,
result.data
)
}
} }
} }
} withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { plugin.get(
plugin.get( getScope,
getScope, selectedApp.packageName,
selectedApp.packageName, selectedApp.version
selectedApp.version )
) }?.takeIf { (_, version) -> selectedApp.version == null || version == selectedApp.version }
}?.takeIf { (_, version) -> selectedApp.version == null || version == selectedApp.version } } catch (e: UserInteractionException.Activity.NotCompleted) {
} catch (e: UserInteractionException.Activity.NotCompleted) { throw e
throw e } catch (_: UserInteractionException) {
} catch (_: UserInteractionException) { null
null }?.let { (data, _) -> download(plugin, data) }
}?.let { (data, _) -> download(plugin, data) } } ?: throw Exception("App is not available.")
} ?: throw Exception("App is not available.") }
} }
is SelectedApp.Local -> selectedApp.file.also { args.setInputFile(it) } is SelectedApp.Local -> selectedApp.file.also { args.setInputFile(it) }
@@ -227,12 +236,12 @@ class PatcherWorker(
args.selectedPatches, args.selectedPatches,
args.options, args.options,
args.logger, args.logger,
args.onPatchCompleted, args.onEvent,
args.onProgress
) )
keystoreManager.sign(patchedApk, File(args.output)) runStep(StepId.SignAPK, args.onEvent) {
updateProgress(state = State.COMPLETED) // Signing keystoreManager.sign(patchedApk, File(args.output))
}
Log.i(tag, "Patching succeeded".logFmt()) Log.i(tag, "Patching succeeded".logFmt())
Result.success() Result.success()
@@ -241,11 +250,11 @@ class PatcherWorker(
tag, tag,
"An exception occurred in the remote process while patching. ${e.originalStackTrace}".logFmt() "An exception occurred in the remote process while patching. ${e.originalStackTrace}".logFmt()
) )
updateProgress(state = State.FAILED, message = e.originalStackTrace) args.onEvent(ProgressEvent.Failed(null, e.toRemoteError())) // Fallback if exception doesn't occur within step
Result.failure() Result.failure()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(tag, "An exception occurred while patching".logFmt(), e) Log.e(tag, "An exception occurred while patching".logFmt(), e)
updateProgress(state = State.FAILED, message = e.stackTraceToString()) args.onEvent(ProgressEvent.Failed(null, e.toRemoteError())) // Fallback if exception doesn't occur within step
Result.failure() Result.failure()
} finally { } finally {
patchedApk.delete() patchedApk.delete()

View File

@@ -1,53 +0,0 @@
package app.revanced.manager.service
import android.app.Service
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import android.os.IBinder
@Suppress("DEPRECATION")
class InstallService : Service() {
override fun onStartCommand(
intent: Intent, flags: Int, startId: Int
): Int {
val extraStatus = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999)
val extraStatusMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
val extraPackageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME)
when (extraStatus) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
startActivity(if (Build.VERSION.SDK_INT >= 33) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
intent.getParcelableExtra(Intent.EXTRA_INTENT)
}.apply {
this?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
})
}
else -> {
sendBroadcast(Intent().apply {
action = APP_INSTALL_ACTION
`package` = packageName
putExtra(EXTRA_INSTALL_STATUS, extraStatus)
putExtra(EXTRA_INSTALL_STATUS_MESSAGE, extraStatusMessage)
putExtra(EXTRA_PACKAGE_NAME, extraPackageName)
})
}
}
stopSelf()
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
companion object {
const val APP_INSTALL_ACTION = "APP_INSTALL_ACTION"
const val EXTRA_INSTALL_STATUS = "EXTRA_INSTALL_STATUS"
const val EXTRA_INSTALL_STATUS_MESSAGE = "EXTRA_INSTALL_STATUS_MESSAGE"
const val EXTRA_PACKAGE_NAME = "EXTRA_PACKAGE_NAME"
}
}

View File

@@ -16,7 +16,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.graphics.vector.rememberVectorPainter
import coil.compose.AsyncImage import coil.compose.AsyncImage
import io.github.fornewid.placeholder.material3.placeholder import com.eygraber.compose.placeholder.material3.placeholder
@Composable @Composable
fun AppIcon( fun AppIcon(

View File

@@ -16,7 +16,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import app.revanced.manager.R import app.revanced.manager.R
import io.github.fornewid.placeholder.material3.placeholder import com.eygraber.compose.placeholder.material3.placeholder
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext

View File

@@ -1,5 +1,6 @@
package app.revanced.manager.ui.component package app.revanced.manager.ui.component
import android.annotation.SuppressLint
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.annotation.StringRes import androidx.annotation.StringRes
@@ -79,7 +80,7 @@ private fun installerStatusDialogButton(
enum class DialogKind( enum class DialogKind(
val flag: Int, val flag: Int,
val title: Int, val title: Int,
@StringRes val contentStringResId: Int, @param:StringRes val contentStringResId: Int,
val icon: ImageVector = Icons.Outlined.ErrorOutline, val icon: ImageVector = Icons.Outlined.ErrorOutline,
val confirmButton: InstallerStatusDialogButton = installerStatusDialogButton(R.string.ok), val confirmButton: InstallerStatusDialogButton = installerStatusDialogButton(R.string.ok),
val dismissButton: InstallerStatusDialogButton? = null, val dismissButton: InstallerStatusDialogButton? = null,
@@ -133,10 +134,8 @@ enum class DialogKind(
title = R.string.installation_storage_issue_dialog_title, title = R.string.installation_storage_issue_dialog_title,
contentStringResId = R.string.installation_storage_issue_description, contentStringResId = R.string.installation_storage_issue_description,
), ),
@RequiresApi(34)
FAILURE_TIMEOUT( FAILURE_TIMEOUT(
flag = PackageInstaller.STATUS_FAILURE_TIMEOUT, flag = @SuppressLint("InlinedApi") PackageInstaller.STATUS_FAILURE_TIMEOUT,
title = R.string.installation_timeout_dialog_title, title = R.string.installation_timeout_dialog_title,
contentStringResId = R.string.installation_timeout_description, contentStringResId = R.string.installation_timeout_description,
confirmButton = installerStatusDialogButton(R.string.install_app) { model -> confirmButton = installerStatusDialogButton(R.string.install_app) { model ->

View File

@@ -14,7 +14,7 @@ fun LoadingIndicator(
progress: () -> Float? = { null }, progress: () -> Float? = { null },
color: Color = ProgressIndicatorDefaults.circularColor, color: Color = ProgressIndicatorDefaults.circularColor,
strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth, strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth,
trackColor: Color = ProgressIndicatorDefaults.circularTrackColor, trackColor: Color = ProgressIndicatorDefaults.circularIndeterminateTrackColor,
strokeCap: StrokeCap = ProgressIndicatorDefaults.CircularDeterminateStrokeCap strokeCap: StrokeCap = ProgressIndicatorDefaults.CircularDeterminateStrokeCap
) { ) {
progress()?.let { progress()?.let {

View File

@@ -18,8 +18,6 @@ fun Markdown(
colors = markdownColor( colors = markdownColor(
text = MaterialTheme.colorScheme.onSurfaceVariant, text = MaterialTheme.colorScheme.onSurfaceVariant,
codeBackground = MaterialTheme.colorScheme.secondaryContainer, codeBackground = MaterialTheme.colorScheme.secondaryContainer,
codeText = MaterialTheme.colorScheme.onSecondaryContainer,
linkText = MaterialTheme.colorScheme.primary
), ),
typography = markdownTypography( typography = markdownTypography(
h1 = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), h1 = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),

View File

@@ -29,7 +29,8 @@ fun SearchBar(
) { ) {
val colors = SearchBarColors( val colors = SearchBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
dividerColor = MaterialTheme.colorScheme.outline dividerColor = MaterialTheme.colorScheme.outline,
inputFieldColors = SearchBarDefaults.inputFieldColors()
) )
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current

View File

@@ -31,7 +31,8 @@ fun SearchView(
) { ) {
val colors = SearchBarColors( val colors = SearchBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
dividerColor = MaterialTheme.colorScheme.outline dividerColor = MaterialTheme.colorScheme.outline,
inputFieldColors = SearchBarDefaults.inputFieldColors()
) )
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current

View File

@@ -1,7 +1,7 @@
package app.revanced.manager.ui.component.patcher package app.revanced.manager.ui.component.patcher
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
@@ -21,6 +20,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -39,11 +39,9 @@ import androidx.compose.ui.unit.dp
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.component.ArrowButton import app.revanced.manager.ui.component.ArrowButton
import app.revanced.manager.ui.component.LoadingIndicator import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.model.ProgressKey
import app.revanced.manager.ui.model.State import app.revanced.manager.ui.model.State
import app.revanced.manager.ui.model.Step
import app.revanced.manager.ui.model.StepCategory import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.ui.model.StepProgressProvider import app.revanced.manager.ui.model.Step
import java.util.Locale import java.util.Locale
import kotlin.math.floor import kotlin.math.floor
@@ -52,21 +50,10 @@ import kotlin.math.floor
fun Steps( fun Steps(
category: StepCategory, category: StepCategory,
steps: List<Step>, steps: List<Step>,
stepCount: Pair<Int, Int>? = null, isExpanded: Boolean = false,
stepProgressProvider: StepProgressProvider onExpand: () -> Unit,
onClick: () -> Unit
) { ) {
var expanded by rememberSaveable { mutableStateOf(true) }
val categoryColor by animateColorAsState(
if (expanded) MaterialTheme.colorScheme.surfaceContainerHigh else Color.Transparent,
label = "category"
)
val cardColor by animateColorAsState(
if (expanded) MaterialTheme.colorScheme.surfaceContainer else Color.Transparent,
label = "card"
)
val state = remember(steps) { val state = remember(steps) {
when { when {
steps.all { it.state == State.COMPLETED } -> State.COMPLETED steps.all { it.state == State.COMPLETED } -> State.COMPLETED
@@ -76,62 +63,69 @@ fun Steps(
} }
} }
val filteredSteps = remember(steps) {
val failedCount = steps.count { it.state == State.FAILED }
steps.filter { step ->
// Show hidden steps if it's the only failed step.
!step.hide || (step.state == State.FAILED && failedCount == 1)
}
}
LaunchedEffect(state) {
if (state == State.RUNNING || state == State.FAILED)
onExpand()
}
Column( Column(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(16.dp)) .clip(MaterialTheme.shapes.large)
.fillMaxWidth() .fillMaxWidth()
.background(cardColor) .background(MaterialTheme.colorScheme.surfaceContainerLow)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(16.dp)) .clickable(true, onClick = onClick)
.clickable { expanded = !expanded } .fillMaxWidth()
.background(categoryColor) .padding(20.dp)
) { ) {
Row( StepIcon(state = state, size = 24.dp)
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(16.dp)
) {
StepIcon(state = state, size = 24.dp)
Text(stringResource(category.displayName)) Text(stringResource(category.displayName))
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
val stepProgress = remember(stepCount, steps) { Text(
stepCount?.let { (current, total) -> "$current/$total" } text = "${filteredSteps.count { it.state == State.COMPLETED }}/${filteredSteps.size}",
?: "${steps.count { it.state == State.COMPLETED }}/${steps.size}" style = MaterialTheme.typography.labelSmall
} )
Text( ArrowButton(modifier = Modifier.size(24.dp), expanded = isExpanded, onClick = null)
text = stepProgress,
style = MaterialTheme.typography.labelSmall
)
ArrowButton(modifier = Modifier.size(24.dp), expanded = expanded, onClick = null)
}
} }
AnimatedVisibility(visible = expanded) { AnimatedVisibility(visible = isExpanded) {
Column( Column(
modifier = Modifier.fillMaxWidth() modifier = Modifier
.background(MaterialTheme.colorScheme.background.copy(0.6f))
.fillMaxWidth()
.padding(top = 10.dp)
) { ) {
steps.forEach { step -> filteredSteps.forEachIndexed { index, step ->
val (progress, progressText) = when (step.progressKey) { val (progress, progressText) = step.progress?.let { (current, total) ->
null -> null if (total != null) current.toFloat() / total.toFloat() to "${current.megaBytes}/${total.megaBytes} MB"
ProgressKey.DOWNLOAD -> stepProgressProvider.downloadProgress?.let { (downloaded, total) -> else null to "${current.megaBytes} MB"
if (total != null) downloaded.toFloat() / total.toFloat() to "${downloaded.megaBytes}/${total.megaBytes} MB"
else null to "${downloaded.megaBytes} MB"
}
} ?: (null to null) } ?: (null to null)
SubStep( SubStep(
name = step.name, name = step.title,
state = step.state, state = step.state,
message = step.message, message = step.message,
progress = progress, progress = progress,
progressText = progressText progressText = progressText,
isFirst = index == 0,
isLast = index == filteredSteps.lastIndex,
) )
} }
} }
@@ -145,7 +139,9 @@ fun SubStep(
state: State, state: State,
message: String? = null, message: String? = null,
progress: Float? = null, progress: Float? = null,
progressText: String? = null progressText: String? = null,
isFirst: Boolean = false,
isLast: Boolean = false,
) { ) {
var messageExpanded by rememberSaveable { mutableStateOf(true) } var messageExpanded by rememberSaveable { mutableStateOf(true) }
@@ -156,22 +152,22 @@ fun SubStep(
clickable { messageExpanded = !messageExpanded } clickable { messageExpanded = !messageExpanded }
else this else this
} }
.padding(top = if (isFirst) 10.dp else 8.dp, bottom = if (isLast) 20.dp else 8.dp)
.padding(horizontal = 20.dp)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) { ) {
Box( StepIcon(
modifier = Modifier.size(24.dp), size = 18.dp,
contentAlignment = Alignment.Center state = state,
) { progress = progress,
StepIcon(state, progress, size = 20.dp) )
}
Text( Text(
text = name, text = name,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.labelLarge,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, true), modifier = Modifier.weight(1f, true),
@@ -201,7 +197,7 @@ fun SubStep(
text = message.orEmpty(), text = message.orEmpty(),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(horizontal = 52.dp, vertical = 8.dp) modifier = Modifier.padding(horizontal = 36.dp, vertical = 8.dp)
) )
} }
} }
@@ -211,40 +207,44 @@ fun SubStep(
fun StepIcon(state: State, progress: Float? = null, size: Dp) { fun StepIcon(state: State, progress: Float? = null, size: Dp) {
val strokeWidth = Dp(floor(size.value / 10) + 1) val strokeWidth = Dp(floor(size.value / 10) + 1)
when (state) { Crossfade(targetState = state, label = "State CrossFade") { state ->
State.COMPLETED -> Icon( when (state) {
Icons.Filled.CheckCircle, State.COMPLETED -> Icon(
contentDescription = stringResource(R.string.step_completed), Icons.Filled.CheckCircle,
tint = MaterialTheme.colorScheme.surfaceTint, contentDescription = stringResource(R.string.step_completed),
modifier = Modifier.size(size) tint = Color(0xFF59B463),
) modifier = Modifier.size(size)
State.FAILED -> Icon(
Icons.Filled.Cancel,
contentDescription = stringResource(R.string.step_failed),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(size)
)
State.WAITING -> Icon(
Icons.Outlined.Circle,
contentDescription = stringResource(R.string.step_waiting),
tint = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.size(size)
)
State.RUNNING ->
LoadingIndicator(
modifier = stringResource(R.string.step_running).let { description ->
Modifier
.size(size)
.semantics {
contentDescription = description
}
},
progress = { progress },
strokeWidth = strokeWidth
) )
State.FAILED -> Icon(
Icons.Filled.Cancel,
contentDescription = stringResource(R.string.step_failed),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(size)
)
State.WAITING -> Icon(
Icons.Outlined.Circle,
contentDescription = stringResource(R.string.step_waiting),
tint = MaterialTheme.colorScheme.onSurface.copy(.2f),
modifier = Modifier.size(size)
)
State.RUNNING -> {
LoadingIndicator(
modifier = stringResource(R.string.step_running).let { description ->
Modifier
.size(size)
.semantics {
contentDescription = description
}
},
progress = { progress },
strokeWidth = strokeWidth
)
}
}
} }
} }

View File

@@ -3,9 +3,10 @@ package app.revanced.manager.ui.model
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.StringRes import androidx.annotation.StringRes
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.patcher.StepId
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
enum class StepCategory(@StringRes val displayName: Int) { enum class StepCategory(@param:StringRes val displayName: Int) {
PREPARING(R.string.patcher_step_group_preparing), PREPARING(R.string.patcher_step_group_preparing),
PATCHING(R.string.patcher_step_group_patching), PATCHING(R.string.patcher_step_group_patching),
SAVING(R.string.patcher_step_group_saving) SAVING(R.string.patcher_step_group_saving)
@@ -15,19 +16,20 @@ enum class State {
WAITING, RUNNING, FAILED, COMPLETED WAITING, RUNNING, FAILED, COMPLETED
} }
enum class ProgressKey {
DOWNLOAD
}
interface StepProgressProvider {
val downloadProgress: Pair<Long, Long?>?
}
@Parcelize @Parcelize
data class Step( data class Step(
val name: String, val id: StepId,
val title: String,
val category: StepCategory, val category: StepCategory,
val state: State = State.WAITING, val state: State = State.WAITING,
val message: String? = null, val message: String? = null,
val progressKey: ProgressKey? = null val progress: Pair<Long, Long?>? = null,
) : Parcelable val hide: Boolean = false,
) : Parcelable
fun Step.withState(
state: State = this.state,
message: String? = this.message,
progress: Pair<Long, Long?>? = this.progress
) = copy(state = state, message = message, progress = progress)

View File

@@ -36,7 +36,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.TabRow import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
@@ -53,6 +53,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -100,6 +101,7 @@ fun DashboardScreen(
false false
) )
val androidContext = LocalContext.current val androidContext = LocalContext.current
val resources = LocalResources.current
val composableScope = rememberCoroutineScope() val composableScope = rememberCoroutineScope()
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
initialPage = DashboardPage.DASHBOARD.ordinal, initialPage = DashboardPage.DASHBOARD.ordinal,
@@ -234,7 +236,7 @@ fun DashboardScreen(
when (pagerState.currentPage) { when (pagerState.currentPage) {
DashboardPage.DASHBOARD.ordinal -> { DashboardPage.DASHBOARD.ordinal -> {
if (availablePatches < 1) { if (availablePatches < 1) {
androidContext.toast(androidContext.getString(R.string.no_patch_found)) androidContext.toast(resources.getString(R.string.no_patch_found))
composableScope.launch { composableScope.launch {
pagerState.animateScrollToPage( pagerState.animateScrollToPage(
DashboardPage.BUNDLES.ordinal DashboardPage.BUNDLES.ordinal
@@ -259,7 +261,7 @@ fun DashboardScreen(
} }
) { paddingValues -> ) { paddingValues ->
Column(Modifier.padding(paddingValues)) { Column(Modifier.padding(paddingValues)) {
TabRow( SecondaryTabRow(
selectedTabIndex = pagerState.currentPage, selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp) containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) { ) {

View File

@@ -40,6 +40,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.revanced.manager.R import app.revanced.manager.R
@@ -69,6 +70,7 @@ fun PatcherScreen(
} }
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current
val exportApkLauncher = val exportApkLauncher =
rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), viewModel::export) rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), viewModel::export)
@@ -79,7 +81,7 @@ fun PatcherScreen(
fun onPageBack() = when { fun onPageBack() = when {
patcherSucceeded == null -> showDismissConfirmationDialog = true patcherSucceeded == null -> showDismissConfirmationDialog = true
viewModel.isInstalling -> context.toast(context.getString(R.string.patcher_install_in_progress)) viewModel.isInstalling -> context.toast(resources.getString(R.string.patcher_install_in_progress))
else -> onLeave() else -> onLeave()
} }
@@ -87,7 +89,7 @@ fun PatcherScreen(
val steps by remember { val steps by remember {
derivedStateOf { derivedStateOf {
viewModel.steps.groupBy { it.category } viewModel.steps.groupBy { it.category }.toList()
} }
} }
@@ -213,6 +215,12 @@ fun PatcherScreen(
.padding(paddingValues) .padding(paddingValues)
.fillMaxSize() .fillMaxSize()
) { ) {
var expandedCategory by rememberSaveable { mutableStateOf<StepCategory?>(null) }
val expandCategory: (StepCategory?) -> Unit = { category ->
expandedCategory = category
}
LinearProgressIndicator( LinearProgressIndicator(
progress = { viewModel.progress }, progress = { viewModel.progress },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@@ -224,14 +232,17 @@ fun PatcherScreen(
contentPadding = PaddingValues(16.dp) contentPadding = PaddingValues(16.dp)
) { ) {
items( items(
items = steps.toList(), items = steps,
key = { it.first } key = { it.first }
) { (category, steps) -> ) { (category, steps) ->
Steps( Steps(
category = category, category = category,
steps = steps, steps = steps,
stepCount = if (category == StepCategory.PATCHING) viewModel.patchesProgress else null, isExpanded = expandedCategory == category,
stepProgressProvider = viewModel onExpand = { expandCategory(category) },
onClick = {
expandCategory(if (expandedCategory == category) null else category)
}
) )
} }
} }

View File

@@ -40,7 +40,7 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.SecondaryScrollableTabRow
import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -49,10 +49,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
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.alpha import androidx.compose.ui.draw.alpha
@@ -81,9 +83,11 @@ import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.isScrollingUp import app.revanced.manager.util.isScrollingUp
import app.revanced.manager.util.transparentListItemColors import app.revanced.manager.util.transparentListItemColors
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, FlowPreview::class)
@Composable @Composable
fun PatchesSelectorScreen( fun PatchesSelectorScreen(
onSave: (PatchSelection?, Options) -> Unit, onSave: (PatchSelection?, Options) -> Unit,
@@ -231,7 +235,8 @@ fun PatchesSelectorScreen(
viewModel.selectionWarningEnabled -> showSelectionWarning = true viewModel.selectionWarningEnabled -> showSelectionWarning = true
// Show universal warning if universal patch is selected and the toggle is off // Show universal warning if universal patch is selected and the toggle is off
patch.compatiblePackages == null && viewModel.universalPatchWarningEnabled -> showUniversalWarning = true patch.compatiblePackages == null && viewModel.universalPatchWarningEnabled -> showUniversalWarning =
true
// Toggle the patch otherwise // Toggle the patch otherwise
else -> viewModel.togglePatch(uid, patch) else -> viewModel.togglePatch(uid, patch)
@@ -360,6 +365,21 @@ fun PatchesSelectorScreen(
) { ) {
Icon(Icons.Outlined.Restore, stringResource(R.string.reset)) Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
} }
val isScrollingUp =
patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp()
val expanded by produceState(true, isScrollingUp) {
val state = isScrollingUp ?: return@produceState
value = state.value
// Use snapshotFlow and sample to prevent the value from changing too often.
snapshotFlow { state.value }
.sample(333L)
.collect {
value = it
}
}
HapticExtendedFloatingActionButton( HapticExtendedFloatingActionButton(
text = { text = {
Text( Text(
@@ -375,8 +395,7 @@ fun PatchesSelectorScreen(
contentDescription = stringResource(R.string.save) contentDescription = stringResource(R.string.save)
) )
}, },
expanded = patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp expanded = expanded,
?: true,
onClick = { onClick = {
onSave(viewModel.getCustomSelection(), viewModel.getOptions()) onSave(viewModel.getCustomSelection(), viewModel.getOptions())
} }
@@ -392,7 +411,7 @@ fun PatchesSelectorScreen(
.padding(top = 16.dp) .padding(top = 16.dp)
) { ) {
if (bundles.size > 1) { if (bundles.size > 1) {
ScrollableTabRow( SecondaryScrollableTabRow(
selectedTabIndex = pagerState.currentPage, selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp) containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) { ) {

View File

@@ -13,7 +13,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.SecondaryScrollableTabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
@@ -106,7 +106,7 @@ fun RequiredOptionsScreen(
.padding(paddingValues) .padding(paddingValues)
) { ) {
if (list.isEmpty()) return@Column if (list.isEmpty()) return@Column
else if (list.size > 1) ScrollableTabRow( else if (list.size > 1) SecondaryScrollableTabRow(
selectedTabIndex = pagerState.currentPage, selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp) containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) { ) {

View File

@@ -25,12 +25,14 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -67,6 +69,7 @@ fun SelectedAppInfoScreen(
vm: SelectedAppInfoViewModel vm: SelectedAppInfoViewModel
) { ) {
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current
val networkInfo = koinInject<NetworkInfo>() val networkInfo = koinInject<NetworkInfo>()
val networkConnected = remember { networkInfo.isConnected() } val networkConnected = remember { networkInfo.isConnected() }
val networkMetered = remember { !networkInfo.isUnmetered() } val networkMetered = remember { !networkInfo.isUnmetered() }
@@ -76,12 +79,12 @@ fun SelectedAppInfoScreen(
val bundles by vm.bundleInfoFlow.collectAsStateWithLifecycle(emptyList()) val bundles by vm.bundleInfoFlow.collectAsStateWithLifecycle(emptyList())
val allowIncompatiblePatches by vm.prefs.disablePatchVersionCompatCheck.getAsState() val allowIncompatiblePatches by vm.prefs.disablePatchVersionCompatCheck.getAsState()
val patches = remember(bundles, allowIncompatiblePatches) { val patches by remember {
vm.getPatches(bundles, allowIncompatiblePatches) derivedStateOf {
} vm.getPatches(bundles, allowIncompatiblePatches)
val selectedPatchCount = remember(patches) { }
patches.values.sumOf { it.size }
} }
val selectedPatchCount = patches.values.sumOf { it.size }
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(), contract = ActivityResultContracts.StartActivityForResult(),
@@ -117,7 +120,7 @@ fun SelectedAppInfoScreen(
}, },
onClick = patchClick@{ onClick = patchClick@{
if (selectedPatchCount == 0) { if (selectedPatchCount == 0) {
context.toast(context.getString(R.string.no_patches_selected)) context.toast(resources.getString(R.string.no_patches_selected))
return@patchClick return@patchClick
} }

View File

@@ -22,8 +22,8 @@ import app.revanced.manager.ui.model.navigation.Settings
import org.koin.compose.koinInject import org.koin.compose.koinInject
private data class Section( private data class Section(
@StringRes val name: Int, @param:StringRes val name: Int,
@StringRes val description: Int, @param:StringRes val description: Int,
val image: ImageVector, val image: ImageVector,
val destination: Settings.Destination, val destination: Settings.Destination,
) )

View File

@@ -1,5 +1,6 @@
package app.revanced.manager.ui.screen.settings package app.revanced.manager.ui.screen.settings
import android.annotation.SuppressLint
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
@@ -40,6 +41,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@@ -67,8 +69,9 @@ fun AboutSettingsScreen(
viewModel: AboutViewModel = koinViewModel() viewModel: AboutViewModel = koinViewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current
// painterResource() is broken on release builds for some reason. // painterResource() is broken on release builds for some reason.
val icon = rememberDrawablePainter(drawable = remember { val icon = rememberDrawablePainter(drawable = remember(resources) {
AppCompatResources.getDrawable(context, R.drawable.ic_logo_ring) AppCompatResources.getDrawable(context, R.drawable.ic_logo_ring)
}) })
@@ -76,7 +79,7 @@ fun AboutSettingsScreen(
viewModel.socials.partition(ReVancedSocial::preferred) viewModel.socials.partition(ReVancedSocial::preferred)
} }
val preferredSocialButtons = remember(preferredSocials, viewModel.donate, viewModel.contact) { val preferredSocialButtons = remember(resources, preferredSocials, viewModel.donate, viewModel.contact) {
preferredSocials.map { preferredSocials.map {
Triple( Triple(
getSocialIcon(it.name), getSocialIcon(it.name),
@@ -89,7 +92,7 @@ fun AboutSettingsScreen(
viewModel.donate?.let { viewModel.donate?.let {
Triple( Triple(
Icons.Outlined.FavoriteBorder, Icons.Outlined.FavoriteBorder,
context.getString(R.string.donate), resources.getString(R.string.donate),
third = { third = {
context.openUrl(it) context.openUrl(it)
} }
@@ -98,7 +101,7 @@ fun AboutSettingsScreen(
viewModel.contact?.let { viewModel.contact?.let {
Triple( Triple(
Icons.Outlined.MailOutline, Icons.Outlined.MailOutline,
context.getString(R.string.contact), resources.getString(R.string.contact),
third = { third = {
context.openUrl("mailto:$it") context.openUrl("mailto:$it")
} }
@@ -131,7 +134,7 @@ fun AboutSettingsScreen(
stringResource(R.string.contributors_description), stringResource(R.string.contributors_description),
third = nav@{ third = nav@{
if (!viewModel.isConnected) { if (!viewModel.isConnected) {
context.toast(context.getString(R.string.no_network_toast)) context.toast(resources.getString(R.string.no_network_toast))
return@nav return@nav
} }
@@ -153,7 +156,7 @@ fun AboutSettingsScreen(
LaunchedEffect(developerTaps) { LaunchedEffect(developerTaps) {
if (developerTaps == 0) return@LaunchedEffect if (developerTaps == 0) return@LaunchedEffect
if (showDeveloperSettings) { if (showDeveloperSettings) {
snackbarHostState.showSnackbar(context.getString(R.string.developer_options_already_enabled)) snackbarHostState.showSnackbar(resources.getString(R.string.developer_options_already_enabled))
developerTaps = 0 developerTaps = 0
return@LaunchedEffect return@LaunchedEffect
} }
@@ -161,7 +164,7 @@ fun AboutSettingsScreen(
val remaining = DEVELOPER_OPTIONS_TAPS - developerTaps val remaining = DEVELOPER_OPTIONS_TAPS - developerTaps
if (remaining > 0) { if (remaining > 0) {
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
context.getString( resources.getString(
R.string.developer_options_taps, R.string.developer_options_taps,
remaining remaining
), ),
@@ -169,7 +172,7 @@ fun AboutSettingsScreen(
) )
} else if (remaining == 0) { } else if (remaining == 0) {
viewModel.showDeveloperSettings.update(true) viewModel.showDeveloperSettings.update(true)
snackbarHostState.showSnackbar(context.getString(R.string.developer_options_enabled)) snackbarHostState.showSnackbar(resources.getString(R.string.developer_options_enabled))
} }
// Reset the counter // Reset the counter

View File

@@ -38,6 +38,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -64,9 +65,10 @@ fun AdvancedSettingsScreen(
viewModel: AdvancedSettingsViewModel = koinViewModel() viewModel: AdvancedSettingsViewModel = koinViewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
val memoryLimit = remember { val resources = LocalResources.current
val memoryLimit = remember(resources) {
val activityManager = context.getSystemService<ActivityManager>()!! val activityManager = context.getSystemService<ActivityManager>()!!
context.getString( resources.getString(
R.string.device_memory_limit_format, R.string.device_memory_limit_format,
activityManager.memoryClass, activityManager.memoryClass,
activityManager.largeMemoryClass activityManager.largeMemoryClass
@@ -183,7 +185,7 @@ fun AdvancedSettingsScreen(
ClipData.newPlainText("Device Information", deviceContent) ClipData.newPlainText("Device Information", deviceContent)
) )
context.toast(context.getString(R.string.toast_copied_to_clipboard)) context.toast(resources.getString(R.string.toast_copied_to_clipboard))
}.withHapticFeedback(HapticFeedbackConstants.LONG_PRESS) }.withHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
), ),
headlineContent = stringResource(R.string.about_device), headlineContent = stringResource(R.string.about_device),

View File

@@ -2,6 +2,8 @@ package app.revanced.manager.ui.screen.settings
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -10,10 +12,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -28,6 +33,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -36,10 +42,10 @@ import app.revanced.manager.R
import app.revanced.manager.network.downloader.DownloaderPluginState import app.revanced.manager.network.downloader.DownloaderPluginState
import app.revanced.manager.ui.component.AppLabel import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ConfirmDialog
import app.revanced.manager.ui.component.ExceptionViewerDialog import app.revanced.manager.ui.component.ExceptionViewerDialog
import app.revanced.manager.ui.component.GroupHeader import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.LazyColumnWithScrollbar import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.ConfirmDialog
import app.revanced.manager.ui.component.haptics.HapticCheckbox import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.DownloadsViewModel import app.revanced.manager.ui.viewmodel.DownloadsViewModel
@@ -52,6 +58,7 @@ fun DownloadsSettingsScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
viewModel: DownloadsViewModel = koinViewModel() viewModel: DownloadsViewModel = koinViewModel()
) { ) {
val context = LocalContext.current
val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList()) val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList())
val pluginStates by viewModel.downloaderPluginStates.collectAsStateWithLifecycle() val pluginStates by viewModel.downloaderPluginStates.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@@ -75,7 +82,7 @@ fun DownloadsSettingsScreen(
onBackClick = onBackClick, onBackClick = onBackClick,
actions = { actions = {
if (viewModel.appSelection.isNotEmpty()) { if (viewModel.appSelection.isNotEmpty()) {
IconButton(onClick = { showDeleteConfirmationDialog = true }) { IconButton(onClick = { viewModel.deleteApps() }) {
Icon(Icons.Default.Delete, stringResource(R.string.delete)) Icon(Icons.Default.Delete, stringResource(R.string.delete))
} }
} }
@@ -121,6 +128,11 @@ fun DownloadsSettingsScreen(
.digest(androidSignature.toByteArray()) .digest(androidSignature.toByteArray())
hash.toHexString(format = HexFormat.UpperCase) hash.toHexString(format = HexFormat.UpperCase)
} }
val appName = remember {
packageInfo.applicationInfo?.loadLabel(context.packageManager)
?.toString()
?: packageName
}
when (state) { when (state) {
is DownloaderPluginState.Loaded -> TrustDialog( is DownloaderPluginState.Loaded -> TrustDialog(
@@ -130,6 +142,8 @@ fun DownloadsSettingsScreen(
packageName, packageName,
signature signature
), ),
pluginName = appName,
signature = signature,
onDismiss = ::dismiss, onDismiss = ::dismiss,
onConfirm = { onConfirm = {
viewModel.revokePluginTrust(packageName) viewModel.revokePluginTrust(packageName)
@@ -147,10 +161,10 @@ fun DownloadsSettingsScreen(
is DownloaderPluginState.Untrusted -> TrustDialog( is DownloaderPluginState.Untrusted -> TrustDialog(
title = R.string.downloader_plugin_trust_dialog_title, title = R.string.downloader_plugin_trust_dialog_title,
body = stringResource( body = stringResource(
R.string.downloader_plugin_trust_dialog_body, R.string.downloader_plugin_trust_dialog_body
packageName,
signature
), ),
pluginName = appName,
signature = signature,
onDismiss = ::dismiss, onDismiss = ::dismiss,
onConfirm = { onConfirm = {
viewModel.trustPlugin(packageName) viewModel.trustPlugin(packageName)
@@ -226,6 +240,8 @@ fun DownloadsSettingsScreen(
private fun TrustDialog( private fun TrustDialog(
@StringRes title: Int, @StringRes title: Int,
body: String, body: String,
pluginName: String,
signature: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirm: () -> Unit onConfirm: () -> Unit
) { ) {
@@ -238,10 +254,39 @@ private fun TrustDialog(
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dismiss)) Text(stringResource(R.string.cancel))
} }
}, },
title = { Text(stringResource(title)) }, title = { Text(stringResource(title)) },
text = { Text(body) } text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(body)
Card {
Column(
Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
stringResource(
R.string.downloader_plugin_trust_dialog_plugin,
pluginName
),
)
OutlinedCard(
colors = CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
)
) {
Text(
stringResource(
R.string.downloader_plugin_trust_dialog_signature,
signature.chunked(2).joinToString(" ")
), modifier = Modifier.padding(12.dp)
)
}
}
}
}
}
) )
} }

View File

@@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
@@ -19,6 +21,7 @@ import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -38,6 +41,7 @@ import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.ui.viewmodel.GeneralSettingsViewModel import app.revanced.manager.ui.viewmodel.GeneralSettingsViewModel
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject import org.koin.compose.koinInject
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -48,6 +52,7 @@ fun GeneralSettingsScreen(
val prefs = viewModel.prefs val prefs = viewModel.prefs
val coroutineScope = viewModel.viewModelScope val coroutineScope = viewModel.viewModelScope
var showThemePicker by rememberSaveable { mutableStateOf(false) } var showThemePicker by rememberSaveable { mutableStateOf(false) }
var showLanguagePicker by rememberSaveable { mutableStateOf(false) }
if (showThemePicker) { if (showThemePicker) {
ThemePicker( ThemePicker(
@@ -55,6 +60,17 @@ fun GeneralSettingsScreen(
onConfirm = { viewModel.setTheme(it) } onConfirm = { viewModel.setTheme(it) }
) )
} }
if (showLanguagePicker) {
LanguagePicker(
supportedLocales = viewModel.getSupportedLocales(),
currentLocale = viewModel.getCurrentLocale(),
onDismiss = { showLanguagePicker = false },
onConfirm = { viewModel.setLocale(it) },
getDisplayName = { viewModel.getLocaleDisplayName(it) }
)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold( Scaffold(
@@ -74,6 +90,24 @@ fun GeneralSettingsScreen(
) { ) {
GroupHeader(stringResource(R.string.appearance)) GroupHeader(stringResource(R.string.appearance))
val currentLocale = viewModel.getCurrentLocale()
val currentLanguageDisplay = remember(currentLocale) {
currentLocale?.let { viewModel.getLocaleDisplayName(it) }
}
SettingsListItem(
modifier = Modifier.clickable { showLanguagePicker = true },
headlineContent = stringResource(R.string.language),
supportingContent = stringResource(R.string.language_description),
trailingContent = {
FilledTonalButton(onClick = { showLanguagePicker = true }) {
Text(
currentLanguageDisplay
?: stringResource(R.string.language_system_default)
)
}
}
)
val theme by prefs.theme.getAsState() val theme by prefs.theme.getAsState()
SettingsListItem( SettingsListItem(
modifier = Modifier.clickable { showThemePicker = true }, modifier = Modifier.clickable { showThemePicker = true },
@@ -105,6 +139,14 @@ fun GeneralSettingsScreen(
description = R.string.pure_black_theme_description description = R.string.pure_black_theme_description
) )
} }
GroupHeader(stringResource(R.string.networking))
BooleanItem(
preference = prefs.allowMeteredNetworks,
coroutineScope = coroutineScope,
headline = R.string.allow_metered_networks,
description = R.string.allow_metered_networks_description
)
} }
} }
} }
@@ -148,4 +190,64 @@ private fun ThemePicker(
} }
} }
) )
}
@Composable
private fun LanguagePicker(
supportedLocales: List<Locale>,
currentLocale: Locale?,
onDismiss: () -> Unit,
onConfirm: (Locale?) -> Unit,
getDisplayName: (Locale) -> String
) {
var selectedLocale by remember { mutableStateOf(currentLocale) }
val systemDefaultString = stringResource(R.string.language_system_default)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.language)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { selectedLocale = null },
verticalAlignment = Alignment.CenterVertically
) {
HapticRadioButton(
selected = selectedLocale == null,
onClick = { selectedLocale = null }
)
Text(systemDefaultString)
}
supportedLocales.forEach { locale ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { selectedLocale = locale },
verticalAlignment = Alignment.CenterVertically
) {
HapticRadioButton(
selected = selectedLocale == locale,
onClick = { selectedLocale = locale }
)
Text(getDisplayName(locale))
}
}
}
},
confirmButton = {
TextButton(
onClick = {
onConfirm(selectedLocale)
onDismiss()
}
) {
Text(stringResource(R.string.apply))
}
}
)
} }

View File

@@ -36,6 +36,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -64,6 +65,7 @@ fun ImportExportSettingsScreen(
vm: ImportExportViewModel = koinViewModel() vm: ImportExportViewModel = koinViewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var selectorDialog by rememberSaveable { mutableStateOf<(@Composable () -> Unit)?>(null) } var selectorDialog by rememberSaveable { mutableStateOf<(@Composable () -> Unit)?>(null) }
@@ -108,7 +110,7 @@ fun ImportExportSettingsScreen(
vm.viewModelScope.launch { vm.viewModelScope.launch {
uiSafe(context, R.string.failed_to_import_keystore, "Failed to import keystore") { uiSafe(context, R.string.failed_to_import_keystore, "Failed to import keystore") {
val result = vm.tryKeystoreImport(alias, pass) val result = vm.tryKeystoreImport(alias, pass)
if (!result) context.toast(context.getString(R.string.import_keystore_wrong_credentials)) if (!result) context.toast(resources.getString(R.string.import_keystore_wrong_credentials))
} }
} }
} }
@@ -166,7 +168,7 @@ fun ImportExportSettingsScreen(
GroupItem( GroupItem(
onClick = { onClick = {
if (!vm.canExport()) { if (!vm.canExport()) {
context.toast(context.getString(R.string.export_keystore_unavailable)) context.toast(resources.getString(R.string.export_keystore_unavailable))
return@GroupItem return@GroupItem
} }
exportKeystoreLauncher.launch("Manager.keystore") exportKeystoreLauncher.launch("Manager.keystore")

View File

@@ -7,15 +7,18 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.component.AppScaffold import app.revanced.manager.ui.component.AppScaffold
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.Scrollbar import app.revanced.manager.ui.component.Scrollbar
import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.LibraryDefaults import com.mikepenz.aboutlibraries.ui.compose.LibraryDefaults
import com.mikepenz.aboutlibraries.ui.compose.libraryColors import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.m3.chipColors
import com.mikepenz.aboutlibraries.ui.compose.m3.libraryColors
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -33,16 +36,23 @@ fun LicensesSettingsScreen(
) { paddingValues -> ) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) { Column(modifier = Modifier.padding(paddingValues)) {
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
val libraries by produceLibraries(R.raw.aboutlibraries)
val chipColors = LibraryDefaults.chipColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
)
LibrariesContainer( LibrariesContainer(
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxSize(),
libraries = libraries,
lazyListState = lazyListState, lazyListState = lazyListState,
colors = LibraryDefaults.libraryColors( colors = LibraryDefaults.libraryColors(
backgroundColor = MaterialTheme.colorScheme.background, libraryBackgroundColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground, libraryContentColor = MaterialTheme.colorScheme.onBackground,
badgeBackgroundColor = MaterialTheme.colorScheme.primary, versionChipColors = chipColors,
badgeContentColor = MaterialTheme.colorScheme.onPrimary, licenseChipColors = chipColors,
fundingChipColors = chipColors,
) )
) )
Scrollbar(lazyListState = lazyListState, modifier = Modifier.padding(paddingValues)) Scrollbar(lazyListState = lazyListState, modifier = Modifier.padding(paddingValues))

View File

@@ -12,6 +12,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
@@ -33,6 +34,7 @@ fun UpdatesSettingsScreen(
vm: UpdatesSettingsViewModel = koinViewModel(), vm: UpdatesSettingsViewModel = koinViewModel(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@@ -57,7 +59,7 @@ fun UpdatesSettingsScreen(
modifier = Modifier.clickable { modifier = Modifier.clickable {
coroutineScope.launch { coroutineScope.launch {
if (!vm.isConnected) { if (!vm.isConnected) {
context.toast(context.getString(R.string.no_network_toast)) context.toast(resources.getString(R.string.no_network_toast))
return@launch return@launch
} }
if (vm.checkForUpdates()) onUpdateClick() if (vm.checkForUpdates()) onUpdateClick()
@@ -70,7 +72,7 @@ fun UpdatesSettingsScreen(
SettingsListItem( SettingsListItem(
modifier = Modifier.clickable { modifier = Modifier.clickable {
if (!vm.isConnected) { if (!vm.isConnected) {
context.toast(context.getString(R.string.no_network_toast)) context.toast(resources.getString(R.string.no_network_toast))
return@clickable return@clickable
} }
onChangelogClick() onChangelogClick()

View File

@@ -54,6 +54,7 @@ class BundleListViewModel : ViewModel(), KoinComponent {
patchBundleRepository.update( patchBundleRepository.update(
*getSelectedSources().filterIsInstance<RemotePatchBundle>().toTypedArray(), *getSelectedSources().filterIsInstance<RemotePatchBundle>().toTypedArray(),
showToast = true, showToast = true,
force = true
) )
} }
} }
@@ -65,7 +66,7 @@ class BundleListViewModel : ViewModel(), KoinComponent {
fun update(src: PatchBundleSource) = viewModelScope.launch { fun update(src: PatchBundleSource) = viewModelScope.launch {
if (src !is RemotePatchBundle) return@launch if (src !is RemotePatchBundle) return@launch
patchBundleRepository.update(src, showToast = true) patchBundleRepository.update(src, showToast = true, force = true)
} }
enum class Event { enum class Event {

View File

@@ -1,17 +1,26 @@
package app.revanced.manager.ui.viewmodel package app.revanced.manager.ui.viewmodel
import android.app.Application
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.ui.theme.Theme import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.util.SupportedLocales
import app.revanced.manager.util.resetListItemColorsCached import app.revanced.manager.util.resetListItemColorsCached
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale
class GeneralSettingsViewModel( class GeneralSettingsViewModel(
private val app: Application,
val prefs: PreferencesManager val prefs: PreferencesManager
) : ViewModel() { ) : ViewModel() {
fun setTheme(theme: Theme) = viewModelScope.launch { fun setTheme(theme: Theme) = viewModelScope.launch {
prefs.theme.update(theme) prefs.theme.update(theme)
resetListItemColorsCached() resetListItemColorsCached()
} }
fun getSupportedLocales() = SupportedLocales.getSupportedLocales(app)
fun getCurrentLocale() = SupportedLocales.getCurrentLocale()
fun setLocale(locale: Locale?) = SupportedLocales.setLocale(locale)
fun getLocaleDisplayName(locale: Locale) = SupportedLocales.getDisplayName(locale)
} }

View File

@@ -36,8 +36,8 @@ import kotlin.io.path.deleteExisting
import kotlin.io.path.inputStream import kotlin.io.path.inputStream
sealed class ResetDialogState( sealed class ResetDialogState(
@StringRes val titleResId: Int, @param:StringRes val titleResId: Int,
@StringRes val descriptionResId: Int, @param:StringRes val descriptionResId: Int,
val onConfirm: () -> Unit, val onConfirm: () -> Unit,
val dialogOptionName: String? = null val dialogOptionName: String? = null
) { ) {

View File

@@ -1,17 +1,11 @@
package app.revanced.manager.ui.viewmodel package app.revanced.manager.ui.viewmodel
import android.app.Application import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
import android.util.Log import android.util.Log
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.revanced.manager.R import app.revanced.manager.R
@@ -19,7 +13,6 @@ import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.data.room.apps.installed.InstalledApp import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.domain.installer.RootInstaller import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.repository.InstalledAppRepository import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.service.UninstallService
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.simpleMessage import app.revanced.manager.util.simpleMessage
@@ -30,6 +23,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import ru.solrudev.ackpine.session.Session
import ru.solrudev.ackpine.uninstaller.UninstallFailure
class InstalledAppInfoViewModel( class InstalledAppInfoViewModel(
packageName: String packageName: String
@@ -87,51 +82,28 @@ class InstalledAppInfoViewModel(
fun uninstall() { fun uninstall() {
val app = installedApp ?: return val app = installedApp ?: return
when (app.installType) { viewModelScope.launch {
InstallType.DEFAULT -> pm.uninstallPackage(app.currentPackageName) when (app.installType) {
InstallType.DEFAULT -> {
InstallType.MOUNT -> viewModelScope.launch { when (val result = pm.uninstallPackage(app.currentPackageName)) {
rootInstaller.uninstall(app.currentPackageName) is Session.State.Failed<UninstallFailure> -> {
installedAppRepository.delete(app) val msg = result.failure.message.orEmpty()
onBackClick() context.toast(
} this@InstalledAppInfoViewModel.context.getString(
} R.string.uninstall_app_fail,
} msg
)
private val uninstallBroadcastReceiver = object : BroadcastReceiver() { )
override fun onReceive(context: Context?, intent: Intent?) { return@launch
when (intent?.action) {
UninstallService.APP_UNINSTALL_ACTION -> {
val extraStatus =
intent.getIntExtra(UninstallService.EXTRA_UNINSTALL_STATUS, -999)
val extraStatusMessage =
intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE)
if (extraStatus == PackageInstaller.STATUS_SUCCESS) {
viewModelScope.launch {
installedApp?.let {
installedAppRepository.delete(it)
}
onBackClick()
} }
} else if (extraStatus != PackageInstaller.STATUS_FAILURE_ABORTED) { Session.State.Succeeded -> {}
this@InstalledAppInfoViewModel.context.toast(this@InstalledAppInfoViewModel.context.getString(R.string.uninstall_app_fail, extraStatusMessage))
} }
} }
}
}
}.also {
ContextCompat.registerReceiver(
context,
it,
IntentFilter(UninstallService.APP_UNINSTALL_ACTION),
ContextCompat.RECEIVER_NOT_EXPORTED
)
}
override fun onCleared() { InstallType.MOUNT -> rootInstaller.uninstall(app.currentPackageName)
super.onCleared() }
context.unregisterReceiver(uninstallBroadcastReceiver) installedAppRepository.delete(app)
onBackClick()
}
} }
} }

View File

@@ -1,11 +1,9 @@
package app.revanced.manager.ui.viewmodel package app.revanced.manager.ui.viewmodel
import android.app.Application import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.pm.PackageInstaller as AndroidPackageInstaller
import android.content.pm.PackageInstaller
import android.net.Uri import android.net.Uri
import android.os.ParcelUuid import android.os.ParcelUuid
import android.util.Log import android.util.Log
@@ -16,7 +14,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.autoSaver import androidx.compose.runtime.saveable.autoSaver
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList import androidx.compose.runtime.toMutableStateList
import androidx.core.content.ContextCompat
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.map import androidx.lifecycle.map
@@ -32,32 +29,35 @@ import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.domain.installer.RootInstaller import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.repository.InstalledAppRepository import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.worker.WorkerRepository import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.StepId
import app.revanced.manager.patcher.logger.LogLevel import app.revanced.manager.patcher.logger.LogLevel
import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.worker.PatcherWorker import app.revanced.manager.patcher.worker.PatcherWorker
import app.revanced.manager.plugin.downloader.PluginHostApi import app.revanced.manager.plugin.downloader.PluginHostApi
import app.revanced.manager.plugin.downloader.UserInteractionException import app.revanced.manager.plugin.downloader.UserInteractionException
import app.revanced.manager.service.InstallService
import app.revanced.manager.service.UninstallService
import app.revanced.manager.ui.model.InstallerModel import app.revanced.manager.ui.model.InstallerModel
import app.revanced.manager.ui.model.ProgressKey
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.State import app.revanced.manager.ui.model.State
import app.revanced.manager.ui.model.Step
import app.revanced.manager.ui.model.StepCategory import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.ui.model.StepProgressProvider import app.revanced.manager.ui.model.Step
import app.revanced.manager.ui.model.navigation.Patcher import app.revanced.manager.ui.model.navigation.Patcher
import app.revanced.manager.ui.model.withState
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.asCode
import app.revanced.manager.util.saveableVar import app.revanced.manager.util.saveableVar
import app.revanced.manager.util.saver.snapshotStateListSaver import app.revanced.manager.util.saver.snapshotStateListSaver
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import app.revanced.manager.util.uiSafe import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -66,6 +66,15 @@ import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
import org.koin.core.component.inject import org.koin.core.component.inject
import ru.solrudev.ackpine.installer.InstallFailure
import ru.solrudev.ackpine.installer.PackageInstaller
import ru.solrudev.ackpine.installer.createSession
import ru.solrudev.ackpine.installer.getSession
import ru.solrudev.ackpine.session.ProgressSession
import ru.solrudev.ackpine.session.Session
import ru.solrudev.ackpine.session.await
import ru.solrudev.ackpine.session.parameters.Confirmation
import ru.solrudev.ackpine.uninstaller.UninstallFailure
import java.io.File import java.io.File
import java.nio.file.Files import java.nio.file.Files
import java.time.Duration import java.time.Duration
@@ -73,7 +82,7 @@ import java.time.Duration
@OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class) @OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class)
class PatcherViewModel( class PatcherViewModel(
private val input: Patcher.ViewModelParams private val input: Patcher.ViewModelParams
) : ViewModel(), KoinComponent, StepProgressProvider, InstallerModel { ) : ViewModel(), KoinComponent, InstallerModel {
private val app: Application by inject() private val app: Application by inject()
private val fs: Filesystem by inject() private val fs: Filesystem by inject()
private val pm: PM by inject() private val pm: PM by inject()
@@ -81,6 +90,7 @@ class PatcherViewModel(
private val installedAppRepository: InstalledAppRepository by inject() private val installedAppRepository: InstalledAppRepository by inject()
private val rootInstaller: RootInstaller by inject() private val rootInstaller: RootInstaller by inject()
private val savedStateHandle: SavedStateHandle = get() private val savedStateHandle: SavedStateHandle = get()
private val ackpineInstaller: PackageInstaller = get()
private var installedApp: InstalledApp? = null private var installedApp: InstalledApp? = null
private val selectedApp = input.selectedApp private val selectedApp = input.selectedApp
@@ -95,7 +105,6 @@ class PatcherViewModel(
mutableStateOf<String?>(null) mutableStateOf<String?>(null)
} }
private set private set
private var ongoingPmSession: Boolean by savedStateHandle.saveableVar { false }
var packageInstallerStatus: Int? by savedStateHandle.saveable( var packageInstallerStatus: Int? by savedStateHandle.saveable(
key = "packageInstallerStatus", key = "packageInstallerStatus",
stateSaver = autoSaver() stateSaver = autoSaver()
@@ -104,7 +113,7 @@ class PatcherViewModel(
} }
private set private set
var isInstalling by mutableStateOf(ongoingPmSession) var isInstalling by mutableStateOf(false)
private set private set
private var currentActivityRequest: Pair<CompletableDeferred<Boolean>, String>? by mutableStateOf( private var currentActivityRequest: Pair<CompletableDeferred<Boolean>, String>? by mutableStateOf(
@@ -123,6 +132,18 @@ class PatcherViewModel(
} }
} }
/**
* This coroutine scope is used to await installations.
* It should not be cancelled on system-initiated process death since that would cancel the installation process.
*/
private val installerCoroutineScope = CoroutineScope(Dispatchers.Main)
/**
* Holds the package name of the Apk we are trying to install.
*/
private var installerPkgName: String by savedStateHandle.saveableVar { "" }
private var installerSessionId: ParcelUuid? by savedStateHandle.saveableVar()
private var inputFile: File? by savedStateHandle.saveableVar() private var inputFile: File? by savedStateHandle.saveableVar()
private val outputFile = tempDir.resolve("output.apk") private val outputFile = tempDir.resolve("output.apk")
@@ -138,35 +159,15 @@ class PatcherViewModel(
} }
} }
private val patchCount = input.selectedPatches.values.sumOf { it.size }
private var completedPatchCount by savedStateHandle.saveable {
// SavedStateHandle.saveable only supports the boxed version.
@Suppress("AutoboxingStateCreation") mutableStateOf(
0
)
}
val patchesProgress get() = completedPatchCount to patchCount
override var downloadProgress by savedStateHandle.saveable(
key = "downloadProgress",
stateSaver = autoSaver()
) {
mutableStateOf<Pair<Long, Long?>?>(null)
}
private set
val steps by savedStateHandle.saveable(saver = snapshotStateListSaver()) { val steps by savedStateHandle.saveable(saver = snapshotStateListSaver()) {
generateSteps( generateSteps(app, input.selectedApp, input.selectedPatches).toMutableStateList()
app,
input.selectedApp
).toMutableStateList()
} }
private var currentStepIndex = 0
val progress by derivedStateOf { val progress by derivedStateOf {
val current = steps.count { val steps = steps.filter { it.id != StepId.ExecutePatches }
it.state == State.COMPLETED && it.category != StepCategory.PATCHING
} + completedPatchCount
val total = steps.size - 1 + patchCount val current = steps.count { it.state == State.COMPLETED }
val total = steps.size
current.toFloat() / total.toFloat() current.toFloat() / total.toFloat()
} }
@@ -174,67 +175,46 @@ class PatcherViewModel(
private val workManager = WorkManager.getInstance(app) private val workManager = WorkManager.getInstance(app)
private val patcherWorkerId by savedStateHandle.saveable<ParcelUuid> { private val patcherWorkerId by savedStateHandle.saveable<ParcelUuid> {
ParcelUuid(workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>( ParcelUuid(
"patching", PatcherWorker.Args( workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
input.selectedApp, "patching", PatcherWorker.Args(
outputFile.path, input.selectedApp,
input.selectedPatches, outputFile.path,
input.options, input.selectedPatches,
logger, input.options,
onDownloadProgress = { logger,
withContext(Dispatchers.Main) { setInputFile = { withContext(Dispatchers.Main) { inputFile = it } },
downloadProgress = it handleStartActivityRequest = { plugin, intent ->
} withContext(Dispatchers.Main) {
}, if (currentActivityRequest != null) throw Exception("Another request is already pending.")
onPatchCompleted = { withContext(Dispatchers.Main) { completedPatchCount += 1 } },
setInputFile = { withContext(Dispatchers.Main) { inputFile = it } },
handleStartActivityRequest = { plugin, intent ->
withContext(Dispatchers.Main) {
if (currentActivityRequest != null) throw Exception("Another request is already pending.")
try {
// Wait for the dialog interaction.
val accepted = with(CompletableDeferred<Boolean>()) {
currentActivityRequest = this to plugin.name
await()
}
if (!accepted) throw UserInteractionException.RequestDenied()
// Launch the activity and wait for the result.
try { try {
with(CompletableDeferred<ActivityResult>()) { // Wait for the dialog interaction.
launchedActivity = this val accepted = with(CompletableDeferred<Boolean>()) {
launchActivityChannel.send(intent) currentActivityRequest = this to plugin.name
await() await()
} }
if (!accepted) throw UserInteractionException.RequestDenied()
// Launch the activity and wait for the result.
try {
with(CompletableDeferred<ActivityResult>()) {
launchedActivity = this
launchActivityChannel.send(intent)
await()
}
} finally {
launchedActivity = null
}
} finally { } finally {
launchedActivity = null currentActivityRequest = null
} }
} finally {
currentActivityRequest = null
} }
} },
}, onEvent = ::handleProgressEvent,
onProgress = { name, state, message -> )
viewModelScope.launch {
steps[currentStepIndex] = steps[currentStepIndex].run {
copy(
name = name ?: this.name,
state = state ?: this.state,
message = message ?: this.message
)
}
if (state == State.COMPLETED && currentStepIndex != steps.lastIndex) {
currentStepIndex++
steps[currentStepIndex] =
steps[currentStepIndex].copy(state = State.RUNNING)
}
}
}
) )
)) )
} }
val patcherSucceeded = val patcherSucceeded =
@@ -246,64 +226,26 @@ class PatcherViewModel(
} }
} }
private val installerBroadcastReceiver = object : BroadcastReceiver() { init {
override fun onReceive(context: Context?, intent: Intent?) { // TODO: detect system-initiated process death during the patching process.
when (intent?.action) {
InstallService.APP_INSTALL_ACTION -> {
val pmStatus = intent.getIntExtra(
InstallService.EXTRA_INSTALL_STATUS,
PackageInstaller.STATUS_FAILURE
)
intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE) installerSessionId?.uuid?.let { id ->
?.let(logger::trace) viewModelScope.launch {
try {
if (pmStatus == PackageInstaller.STATUS_SUCCESS) { isInstalling = true
app.toast(app.getString(R.string.install_app_success)) uiSafe(app, R.string.install_app_fail, "Failed to install") {
installedPackageName = // The process was killed during installation. Await the session again.
intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME) withContext(Dispatchers.IO) {
viewModelScope.launch { ackpineInstaller.getSession(id)
installedAppRepository.addOrUpdate( }?.let {
installedPackageName!!, awaitInstallation(it)
packageName,
input.selectedApp.version
?: pm.getPackageInfo(outputFile)?.versionName!!,
InstallType.DEFAULT,
input.selectedPatches
)
} }
} else packageInstallerStatus = pmStatus }
} finally {
isInstalling = false isInstalling = false
} }
UninstallService.APP_UNINSTALL_ACTION -> {
val pmStatus = intent.getIntExtra(
UninstallService.EXTRA_UNINSTALL_STATUS,
PackageInstaller.STATUS_FAILURE
)
intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE)
?.let(logger::trace)
if (pmStatus != PackageInstaller.STATUS_SUCCESS)
packageInstallerStatus = pmStatus
}
} }
} }
}
init {
// TODO: detect system-initiated process death during the patching process.
ContextCompat.registerReceiver(
app,
installerBroadcastReceiver,
IntentFilter().apply {
addAction(InstallService.APP_INSTALL_ACTION)
addAction(UninstallService.APP_UNINSTALL_ACTION)
},
ContextCompat.RECEIVER_NOT_EXPORTED
)
viewModelScope.launch { viewModelScope.launch {
installedApp = installedAppRepository.get(packageName) installedApp = installedAppRepository.get(packageName)
@@ -313,7 +255,6 @@ class PatcherViewModel(
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
app.unregisterReceiver(installerBroadcastReceiver)
workManager.cancelWorkById(patcherWorkerId.uuid) workManager.cancelWorkById(patcherWorkerId.uuid)
if (input.selectedApp is SelectedApp.Installed && installedApp?.installType == InstallType.MOUNT) { if (input.selectedApp is SelectedApp.Installed && installedApp?.installType == InstallType.MOUNT) {
@@ -327,7 +268,37 @@ class PatcherViewModel(
} }
} }
private fun handleProgressEvent(event: ProgressEvent) = viewModelScope.launch {
val stepIndex = steps.indexOfFirst {
event.stepId?.let { id -> id == it.id }
?: (it.state == State.RUNNING || it.state == State.WAITING)
}
if (stepIndex != -1) steps[stepIndex] = steps[stepIndex].run {
when (event) {
is ProgressEvent.Started -> withState(State.RUNNING)
is ProgressEvent.Progress -> withState(
message = event.message ?: message,
progress = event.current?.let { event.current to event.total } ?: progress
)
is ProgressEvent.Completed -> withState(State.COMPLETED, progress = null)
is ProgressEvent.Failed -> {
if (event.stepId == null && steps.any { it.state == State.FAILED }) return@launch
withState(
State.FAILED,
message = event.error.stackTrace,
progress = null
)
}
}
}
}
fun onBack() { fun onBack() {
installerCoroutineScope.cancel()
// tempDir cannot be deleted inside onCleared because it gets called on system-initiated process death. // tempDir cannot be deleted inside onCleared because it gets called on system-initiated process death.
tempDir.deleteRecursively() tempDir.deleteRecursively()
} }
@@ -372,44 +343,93 @@ class PatcherViewModel(
fun open() = installedPackageName?.let(pm::launch) fun open() = installedPackageName?.let(pm::launch)
fun install(installType: InstallType) = viewModelScope.launch { private suspend fun startInstallation(file: File, packageName: String) {
var pmInstallStarted = false val session = withContext(Dispatchers.IO) {
try { ackpineInstaller.createSession(Uri.fromFile(file)) {
isInstalling = true confirmation = Confirmation.IMMEDIATE
}
}
withContext(Dispatchers.Main) {
installerPkgName = packageName
}
awaitInstallation(session)
}
val currentPackageInfo = pm.getPackageInfo(outputFile) private suspend fun awaitInstallation(session: ProgressSession<InstallFailure>) = withContext(
?: throw Exception("Failed to load application info") Dispatchers.Main
) {
// If the app is currently installed val result = installerCoroutineScope.async {
val existingPackageInfo = pm.getPackageInfo(currentPackageInfo.packageName) try {
if (existingPackageInfo != null) { installerSessionId = ParcelUuid(session.id)
// Check if the app version is less than the installed version withContext(Dispatchers.IO) {
if (pm.getVersionCode(currentPackageInfo) < pm.getVersionCode(existingPackageInfo)) { session.await()
// Exit if the selected app version is less than the installed version
packageInstallerStatus = PackageInstaller.STATUS_FAILURE_CONFLICT
return@launch
} }
} finally {
installerSessionId = null
}
}.await()
when (result) {
is Session.State.Failed<InstallFailure> -> {
result.failure.message?.let(logger::trace)
packageInstallerStatus = result.failure.asCode()
} }
when (installType) { Session.State.Succeeded -> {
InstallType.DEFAULT -> { app.toast(app.getString(R.string.install_app_success))
// Check if the app is mounted as root installedPackageName = installerPkgName
// If it is, unmount it first, silently installedAppRepository.addOrUpdate(
if (rootInstaller.hasRootAccess() && rootInstaller.isAppMounted(packageName)) { installerPkgName,
rootInstaller.unmount(packageName) packageName,
} input.selectedApp.version
?: withContext(Dispatchers.IO) { pm.getPackageInfo(outputFile)?.versionName!! },
InstallType.DEFAULT,
input.selectedPatches
)
}
}
}
// Install regularly fun install(installType: InstallType) = viewModelScope.launch {
pm.installApp(listOf(outputFile)) isInstalling = true
pmInstallStarted = true var needsRootUninstall = false
try {
uiSafe(app, R.string.install_app_fail, "Failed to install") {
val currentPackageInfo =
withContext(Dispatchers.IO) { pm.getPackageInfo(outputFile) }
?: throw Exception("Failed to load application info")
// If the app is currently installed
val existingPackageInfo =
withContext(Dispatchers.IO) { pm.getPackageInfo(currentPackageInfo.packageName) }
if (existingPackageInfo != null) {
// Check if the app version is less than the installed version
if (
pm.getVersionCode(currentPackageInfo) < pm.getVersionCode(
existingPackageInfo
)
) {
// Exit if the selected app version is less than the installed version
packageInstallerStatus = AndroidPackageInstaller.STATUS_FAILURE_CONFLICT
return@launch
}
} }
InstallType.MOUNT -> { when (installType) {
try { InstallType.DEFAULT -> {
val packageInfo = pm.getPackageInfo(outputFile) // Check if the app is mounted as root
?: throw Exception("Failed to load application info") // If it is, unmount it first, silently
if (rootInstaller.hasRootAccess() && rootInstaller.isAppMounted(packageName)) {
rootInstaller.unmount(packageName)
}
// Install regularly
startInstallation(outputFile, currentPackageInfo.packageName)
}
InstallType.MOUNT -> {
val label = with(pm) { val label = with(pm) {
packageInfo.label() currentPackageInfo.label()
} }
// Check for base APK, first check if the app is already installed // Check for base APK, first check if the app is already installed
@@ -417,15 +437,17 @@ class PatcherViewModel(
// If the app is not installed, check if the output file is a base apk // If the app is not installed, check if the output file is a base apk
if (currentPackageInfo.splitNames.isNotEmpty()) { if (currentPackageInfo.splitNames.isNotEmpty()) {
// Exit if there is no base APK package // Exit if there is no base APK package
packageInstallerStatus = PackageInstaller.STATUS_FAILURE_INVALID packageInstallerStatus =
AndroidPackageInstaller.STATUS_FAILURE_INVALID
return@launch return@launch
} }
} }
val inputVersion = input.selectedApp.version val inputVersion = input.selectedApp.version
?: inputFile?.let(pm::getPackageInfo)?.versionName ?: withContext(Dispatchers.IO) { inputFile?.let(pm::getPackageInfo)?.versionName }
?: throw Exception("Failed to determine input APK version") ?: throw Exception("Failed to determine input APK version")
needsRootUninstall = true
// Install as root // Install as root
rootInstaller.install( rootInstaller.install(
outputFile, outputFile,
@@ -436,7 +458,7 @@ class PatcherViewModel(
) )
installedAppRepository.addOrUpdate( installedAppRepository.addOrUpdate(
packageInfo.packageName, currentPackageInfo.packageName,
packageName, packageName,
inputVersion, inputVersion,
InstallType.MOUNT, InstallType.MOUNT,
@@ -448,21 +470,20 @@ class PatcherViewModel(
installedPackageName = packageName installedPackageName = packageName
app.toast(app.getString(R.string.install_app_success)) app.toast(app.getString(R.string.install_app_success))
} catch (e: Exception) { needsRootUninstall = false
Log.e(tag, "Failed to install as root", e)
app.toast(app.getString(R.string.install_app_fail, e.simpleMessage()))
try {
rootInstaller.uninstall(packageName)
} catch (_: Exception) {
}
} }
} }
} }
} catch (e: Exception) {
Log.e(tag, "Failed to install", e)
app.toast(app.getString(R.string.install_app_fail, e.simpleMessage()))
} finally { } finally {
if (!pmInstallStarted) isInstalling = false isInstalling = false
if (needsRootUninstall) {
try {
withContext(NonCancellable) {
rootInstaller.uninstall(packageName)
}
} catch (_: Exception) {
}
}
} }
} }
@@ -473,12 +494,27 @@ class PatcherViewModel(
override fun reinstall() { override fun reinstall() {
viewModelScope.launch { viewModelScope.launch {
uiSafe(app, R.string.reinstall_app_fail, "Failed to reinstall") { try {
pm.getPackageInfo(outputFile)?.packageName?.let { pm.uninstallPackage(it) }
?: throw Exception("Failed to load application info")
pm.installApp(listOf(outputFile))
isInstalling = true isInstalling = true
uiSafe(app, R.string.reinstall_app_fail, "Failed to reinstall") {
val pkgName = withContext(Dispatchers.IO) {
pm.getPackageInfo(outputFile)?.packageName
?: throw Exception("Failed to load application info")
}
when (val result = pm.uninstallPackage(pkgName)) {
is Session.State.Failed<UninstallFailure> -> {
result.failure.message?.let(logger::trace)
packageInstallerStatus = result.failure.asCode()
return@launch
}
Session.State.Succeeded -> {}
}
startInstallation(outputFile, pkgName)
}
} finally {
isInstalling = false
} }
} }
} }
@@ -497,34 +533,66 @@ class PatcherViewModel(
LogLevel.ERROR -> Log.e(TAG, msg) LogLevel.ERROR -> Log.e(TAG, msg)
} }
fun generateSteps(context: Context, selectedApp: SelectedApp): List<Step> { fun generateSteps(
val needsDownload = context: Context,
selectedApp is SelectedApp.Download || selectedApp is SelectedApp.Search selectedApp: SelectedApp,
selectedPatches: PatchSelection
): List<Step> = buildList {
if (selectedApp is SelectedApp.Download || selectedApp is SelectedApp.Search)
add(
Step(
StepId.DownloadAPK,
context.getString(R.string.download_apk),
StepCategory.PREPARING
)
)
return listOfNotNull( add(
Step(
context.getString(R.string.download_apk),
StepCategory.PREPARING,
state = State.RUNNING,
progressKey = ProgressKey.DOWNLOAD,
).takeIf { needsDownload },
Step( Step(
StepId.LoadPatches,
context.getString(R.string.patcher_step_load_patches), context.getString(R.string.patcher_step_load_patches),
StepCategory.PREPARING, StepCategory.PREPARING
state = if (needsDownload) State.WAITING else State.RUNNING, )
), )
add(
Step( Step(
StepId.ReadAPK,
context.getString(R.string.patcher_step_unpack), context.getString(R.string.patcher_step_unpack),
StepCategory.PREPARING StepCategory.PREPARING
), )
)
add(
Step( Step(
StepId.ExecutePatches,
context.getString(R.string.execute_patches), context.getString(R.string.execute_patches),
StepCategory.PATCHING StepCategory.PATCHING,
), hide = true
)
)
Step(context.getString(R.string.patcher_step_write_patched), StepCategory.SAVING), selectedPatches.values.asSequence().flatten().sorted().forEachIndexed { index, name ->
Step(context.getString(R.string.patcher_step_sign_apk), StepCategory.SAVING) add(
Step(
StepId.ExecutePatch(index),
name,
StepCategory.PATCHING
)
)
}
add(
Step(
StepId.WriteAPK,
context.getString(R.string.patcher_step_write_patched),
StepCategory.SAVING
)
)
add(
Step(
StepId.SignAPK,
context.getString(R.string.patcher_step_sign_apk),
StepCategory.SAVING
)
) )
} }
} }

View File

@@ -8,7 +8,6 @@ import android.os.Parcelable
import android.util.Log import android.util.Log
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -129,8 +128,6 @@ class SelectedAppInfoViewModel(
} }
var options: Options by savedStateHandle.saveable { var options: Options by savedStateHandle.saveable {
val state = mutableStateOf<Options>(emptyMap())
viewModelScope.launch { viewModelScope.launch {
if (!persistConfiguration) return@launch // TODO: save options for patched apps. if (!persistConfiguration) return@launch // TODO: save options for patched apps.
val bundlePatches = bundleInfoFlow.first() val bundlePatches = bundleInfoFlow.first()
@@ -141,7 +138,7 @@ class SelectedAppInfoViewModel(
} }
} }
state mutableStateOf(emptyMap())
} }
private set private set
@@ -149,8 +146,6 @@ class SelectedAppInfoViewModel(
if (input.patches != null) if (input.patches != null)
return@saveable mutableStateOf(SelectionState.Customized(input.patches)) return@saveable mutableStateOf(SelectionState.Customized(input.patches))
val selection: MutableState<SelectionState> = mutableStateOf(SelectionState.Default)
// Try to get the previous selection if customization is enabled. // Try to get the previous selection if customization is enabled.
viewModelScope.launch { viewModelScope.launch {
if (!prefs.disableSelectionWarning.get()) return@launch if (!prefs.disableSelectionWarning.get()) return@launch
@@ -160,7 +155,7 @@ class SelectedAppInfoViewModel(
selectionState = SelectionState.Customized(previous) selectionState = SelectionState.Customized(previous)
} }
selection mutableStateOf(SelectionState.Default)
} }
var showSourceSelector by mutableStateOf(false) var showSourceSelector by mutableStateOf(false)
@@ -311,7 +306,7 @@ class SelectedAppInfoViewModel(
} }
} }
enum class Error(@StringRes val resourceId: Int) { enum class Error(@param:StringRes val resourceId: Int) {
NoPlugins(R.string.downloader_no_plugins_available) NoPlugins(R.string.downloader_no_plugins_available)
} }

View File

@@ -1,18 +1,13 @@
package app.revanced.manager.ui.viewmodel package app.revanced.manager.ui.viewmodel
import android.app.Application import android.app.Application
import android.content.BroadcastReceiver import android.net.Uri
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.revanced.manager.R import app.revanced.manager.R
@@ -21,8 +16,6 @@ import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.dto.ReVancedAsset import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.network.service.HttpService import app.revanced.manager.network.service.HttpService
import app.revanced.manager.service.InstallService
import app.revanced.manager.util.PM
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import app.revanced.manager.util.uiSafe import app.revanced.manager.util.uiSafe
import io.ktor.client.plugins.onDownload import io.ktor.client.plugins.onDownload
@@ -31,7 +24,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject import org.koin.core.component.inject
import ru.solrudev.ackpine.installer.InstallFailure
import ru.solrudev.ackpine.installer.PackageInstaller
import ru.solrudev.ackpine.installer.createSession
import ru.solrudev.ackpine.session.Session
import ru.solrudev.ackpine.session.await
import ru.solrudev.ackpine.session.parameters.Confirmation
class UpdateViewModel( class UpdateViewModel(
private val downloadOnScreenEntry: Boolean private val downloadOnScreenEntry: Boolean
@@ -39,10 +39,11 @@ class UpdateViewModel(
private val app: Application by inject() private val app: Application by inject()
private val reVancedAPI: ReVancedAPI by inject() private val reVancedAPI: ReVancedAPI by inject()
private val http: HttpService by inject() private val http: HttpService by inject()
private val pm: PM by inject()
private val networkInfo: NetworkInfo by inject() private val networkInfo: NetworkInfo by inject()
private val fs: Filesystem by inject() private val fs: Filesystem by inject()
private val ackpineInstaller: PackageInstaller = get()
// TODO: save state to handle process death.
var downloadedSize by mutableLongStateOf(0L) var downloadedSize by mutableLongStateOf(0L)
private set private set
var totalSize by mutableLongStateOf(0L) var totalSize by mutableLongStateOf(0L)
@@ -62,14 +63,17 @@ class UpdateViewModel(
private set private set
private val location = fs.tempDir.resolve("updater.apk") private val location = fs.tempDir.resolve("updater.apk")
private val job = viewModelScope.launch {
uiSafe(app, R.string.download_manager_failed, "Failed to download ReVanced Manager") {
releaseInfo = reVancedAPI.getAppUpdate() ?: throw Exception("No update available")
if (downloadOnScreenEntry) { init {
downloadUpdate() viewModelScope.launch {
} else { uiSafe(app, R.string.download_manager_failed, "Failed to download ReVanced Manager") {
state = State.CAN_DOWNLOAD releaseInfo = reVancedAPI.getAppUpdate() ?: throw Exception("No update available")
if (downloadOnScreenEntry) {
downloadUpdate()
} else {
state = State.CAN_DOWNLOAD
}
} }
} }
} }
@@ -78,7 +82,7 @@ class UpdateViewModel(
uiSafe(app, R.string.failed_to_download_update, "Failed to download update") { uiSafe(app, R.string.failed_to_download_update, "Failed to download update") {
val release = releaseInfo!! val release = releaseInfo!!
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (!networkInfo.isSafe() && !ignoreInternetCheck) { if (!networkInfo.isSafe(false) && !ignoreInternetCheck) {
showInternetCheckDialog = true showInternetCheckDialog = true
} else { } else {
state = State.DOWNLOADING state = State.DOWNLOADING
@@ -86,8 +90,10 @@ class UpdateViewModel(
http.download(location) { http.download(location) {
url(release.downloadUrl) url(release.downloadUrl)
onDownload { bytesSentTotal, contentLength -> onDownload { bytesSentTotal, contentLength ->
downloadedSize = bytesSentTotal withContext(Dispatchers.Main) {
totalSize = contentLength downloadedSize = bytesSentTotal
contentLength?.let { totalSize = it }
}
} }
} }
installUpdate() installUpdate()
@@ -98,50 +104,36 @@ class UpdateViewModel(
fun installUpdate() = viewModelScope.launch { fun installUpdate() = viewModelScope.launch {
state = State.INSTALLING state = State.INSTALLING
val result = withContext(Dispatchers.IO) {
ackpineInstaller.createSession(Uri.fromFile(location)) {
confirmation = Confirmation.IMMEDIATE
}.await()
}
pm.installApp(listOf(location)) when (result) {
} is Session.State.Failed<InstallFailure> -> when (val failure = result.failure) {
is InstallFailure.Aborted -> state = State.CAN_INSTALL
private val installBroadcastReceiver = object : BroadcastReceiver() { else -> {
override fun onReceive(context: Context?, intent: Intent?) { val msg = failure.message.orEmpty()
intent?.let { app.toast(app.getString(R.string.install_app_fail, msg))
val pmStatus = intent.getIntExtra(InstallService.EXTRA_INSTALL_STATUS, -999) installError = msg
val extra = state = State.FAILED
intent.getStringExtra(InstallService.EXTRA_INSTALL_STATUS_MESSAGE)!!
when(pmStatus) {
PackageInstaller.STATUS_SUCCESS -> {
app.toast(app.getString(R.string.install_app_success))
state = State.SUCCESS
}
PackageInstaller.STATUS_FAILURE_ABORTED -> {
state = State.CAN_INSTALL
}
else -> {
app.toast(app.getString(R.string.install_app_fail, extra))
installError = extra
state = State.FAILED
}
} }
} }
Session.State.Succeeded -> {
app.toast(app.getString(R.string.install_app_success))
state = State.SUCCESS
}
} }
} }
init {
ContextCompat.registerReceiver(app, installBroadcastReceiver, IntentFilter().apply {
addAction(InstallService.APP_INSTALL_ACTION)
}, ContextCompat.RECEIVER_NOT_EXPORTED)
}
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
app.unregisterReceiver(installBroadcastReceiver)
job.cancel()
location.delete() location.delete()
} }
enum class State(@StringRes val title: Int) { enum class State(@param:StringRes val title: Int) {
CAN_DOWNLOAD(R.string.update_available), CAN_DOWNLOAD(R.string.update_available),
DOWNLOADING(R.string.downloading_manager_update), DOWNLOADING(R.string.downloading_manager_update),
CAN_INSTALL(R.string.ready_to_install_update), CAN_INSTALL(R.string.ready_to_install_update),

View File

@@ -0,0 +1,30 @@
package app.revanced.manager.util
import android.annotation.SuppressLint
import android.content.pm.PackageInstaller
import ru.solrudev.ackpine.installer.InstallFailure
import ru.solrudev.ackpine.uninstaller.UninstallFailure
/**
* Converts an Ackpine installation failure into a PM status code
*/
fun InstallFailure.asCode() = when (this) {
is InstallFailure.Aborted -> PackageInstaller.STATUS_FAILURE_ABORTED
is InstallFailure.Blocked -> PackageInstaller.STATUS_FAILURE_BLOCKED
is InstallFailure.Conflict -> PackageInstaller.STATUS_FAILURE_CONFLICT
is InstallFailure.Incompatible -> PackageInstaller.STATUS_FAILURE_INCOMPATIBLE
is InstallFailure.Invalid -> PackageInstaller.STATUS_FAILURE_INVALID
is InstallFailure.Storage -> PackageInstaller.STATUS_FAILURE_STORAGE
is InstallFailure.Timeout -> @SuppressLint("InlinedApi") PackageInstaller.STATUS_FAILURE_TIMEOUT
else -> PackageInstaller.STATUS_FAILURE
}
/**
* Converts an Ackpine uninstallation failure into a PM status code
*/
fun UninstallFailure.asCode() = when (this) {
is UninstallFailure.Aborted -> PackageInstaller.STATUS_FAILURE_ABORTED
is UninstallFailure.Blocked -> PackageInstaller.STATUS_FAILURE_BLOCKED
is UninstallFailure.Conflict -> PackageInstaller.STATUS_FAILURE_CONFLICT
else -> PackageInstaller.STATUS_FAILURE
}

View File

@@ -2,11 +2,8 @@ package app.revanced.manager.util
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Application import android.app.Application
import android.app.PendingIntent
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.PackageManager.PackageInfoFlags import android.content.pm.PackageManager.PackageInfoFlags
import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.PackageManager.NameNotFoundException
@@ -16,8 +13,6 @@ import android.os.Build
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.service.InstallService
import app.revanced.manager.service.UninstallService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -25,10 +20,13 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import ru.solrudev.ackpine.session.await
import ru.solrudev.ackpine.session.parameters.Confirmation
import ru.solrudev.ackpine.uninstaller.PackageUninstaller
import ru.solrudev.ackpine.uninstaller.createSession
import ru.solrudev.ackpine.uninstaller.parameters.UninstallParametersDsl
import java.io.File import java.io.File
private const val byteArraySize = 1024 * 1024 // Because 1,048,576 is not readable
@Immutable @Immutable
@Parcelize @Parcelize
data class AppInfo( data class AppInfo(
@@ -40,7 +38,8 @@ data class AppInfo(
@SuppressLint("QueryPermissionsNeeded") @SuppressLint("QueryPermissionsNeeded")
class PM( class PM(
private val app: Application, private val app: Application,
patchBundleRepository: PatchBundleRepository patchBundleRepository: PatchBundleRepository,
private val uninstaller: PackageUninstaller
) { ) {
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
@@ -145,17 +144,11 @@ class PM(
false false
) )
suspend fun installApp(apks: List<File>) = withContext(Dispatchers.IO) { suspend fun uninstallPackage(pkg: String, config: UninstallParametersDsl.() -> Unit = {}) = withContext(Dispatchers.IO) {
val packageInstaller = app.packageManager.packageInstaller uninstaller.createSession(pkg) {
packageInstaller.openSession(packageInstaller.createSession(sessionParams)).use { session -> confirmation = Confirmation.IMMEDIATE
apks.forEach { apk -> session.writeApk(apk) } config()
session.commit(app.installIntentSender) }.await()
}
}
fun uninstallPackage(pkg: String) {
val packageInstaller = app.packageManager.packageInstaller
packageInstaller.uninstall(pkg, app.uninstallIntentSender)
} }
fun launch(pkg: String) = app.packageManager.getLaunchIntentForPackage(pkg)?.let { fun launch(pkg: String) = app.packageManager.getLaunchIntentForPackage(pkg)?.let {
@@ -164,44 +157,4 @@ class PM(
} }
fun canInstallPackages() = app.packageManager.canRequestPackageInstalls() fun canInstallPackages() = app.packageManager.canRequestPackageInstalls()
private fun PackageInstaller.Session.writeApk(apk: File) {
apk.inputStream().use { inputStream ->
openWrite(apk.name, 0, apk.length()).use { outputStream ->
inputStream.copyTo(outputStream, byteArraySize)
fsync(outputStream)
}
}
}
private val intentFlags
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
PendingIntent.FLAG_MUTABLE
else
0
private val sessionParams
get() = PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL
).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
setRequestUpdateOwnership(true)
setInstallReason(PackageManager.INSTALL_REASON_USER)
}
private val Context.installIntentSender
get() = PendingIntent.getService(
this,
0,
Intent(this, InstallService::class.java),
intentFlags
).intentSender
private val Context.uninstallIntentSender
get() = PendingIntent.getService(
this,
0,
Intent(this, UninstallService::class.java),
intentFlags
).intentSender
} }

View File

@@ -0,0 +1,40 @@
package app.revanced.manager.util
import android.app.LocaleConfig
import android.content.Context
import android.os.Build
import android.os.LocaleList
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import app.revanced.manager.BuildConfig
import java.util.Locale
object SupportedLocales {
fun getSupportedLocales(context: Context): List<Locale> {
var result: List<Locale>? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) result = runCatching {
LocaleConfig(context).supportedLocales?.toList()
}.getOrNull()
return result ?: generated
}
fun getCurrentLocale(): Locale? =
AppCompatDelegate.getApplicationLocales().takeIf { !it.isEmpty }?.get(0)
fun setLocale(locale: Locale?) = AppCompatDelegate.setApplicationLocales(
locale?.let { LocaleListCompat.create(it) } ?: LocaleListCompat.getEmptyLocaleList()
)
fun getDisplayName(locale: Locale) =
locale.getDisplayName(locale).replaceFirstChar { it.uppercase(locale) }
private fun LocaleList.toList() = (0 until size()).map { get(it) }
private val generated by lazy {
listOf(
Locale.ENGLISH,
*BuildConfig.SUPPORTED_LOCALES.map(Locale::forLanguageTag).toTypedArray()
)
}
}

View File

@@ -15,12 +15,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -33,6 +31,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -42,7 +41,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.format.MonthNames import kotlinx.datetime.format.MonthNames
@@ -50,9 +48,11 @@ import kotlinx.datetime.format.char
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import java.util.Locale import java.util.Locale
import kotlin.math.abs
import kotlin.properties.PropertyDelegateProvider import kotlin.properties.PropertyDelegateProvider
import kotlin.properties.ReadWriteProperty import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
import kotlin.time.Clock
typealias PatchSelection = Map<Int, Set<String>> typealias PatchSelection = Map<Int, Set<String>>
typealias Options = Map<Int, Map<String, Map<String, Any?>>> typealias Options = Map<Int, Map<String, Map<String, Any?>>>
@@ -82,6 +82,8 @@ fun Context.toast(string: String, duration: Int = Toast.LENGTH_SHORT) {
inline fun uiSafe(context: Context, @StringRes toastMsg: Int, logMsg: String, block: () -> Unit) { inline fun uiSafe(context: Context, @StringRes toastMsg: Int, logMsg: String, block: () -> Unit) {
try { try {
block() block()
} catch (e: CancellationException) {
throw e
} catch (error: Exception) { } catch (error: Exception) {
// You can only toast on the main thread. // You can only toast on the main thread.
GlobalScope.launch(Dispatchers.Main) { GlobalScope.launch(Dispatchers.Main) {
@@ -166,7 +168,7 @@ fun LocalDateTime.relativeTime(context: Context): String {
else -> LocalDateTime.Format { else -> LocalDateTime.Format {
monthName(MonthNames.ENGLISH_ABBREVIATED) monthName(MonthNames.ENGLISH_ABBREVIATED)
char(' ') char(' ')
dayOfMonth() day()
if (now.toLocalDateTime(TimeZone.UTC).year != this@relativeTime.year) { if (now.toLocalDateTime(TimeZone.UTC).year != this@relativeTime.year) {
chars(", ") chars(", ")
year() year()
@@ -193,7 +195,12 @@ val transparentListItemColors
.also { transparentListItemColorsCached = it } .also { transparentListItemColorsCached = it }
@Composable @Composable
fun <T> EventEffect(flow: Flow<T>, vararg keys: Any?, state: Lifecycle.State = Lifecycle.State.STARTED, block: suspend (T) -> Unit) { fun <T> EventEffect(
flow: Flow<T>,
vararg keys: Any?,
state: Lifecycle.State = Lifecycle.State.STARTED,
block: suspend (T) -> Unit
) {
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val currentBlock by rememberUpdatedState(block) val currentBlock by rememberUpdatedState(block)
@@ -209,40 +216,36 @@ fun <T> EventEffect(flow: Flow<T>, vararg keys: Any?, state: Lifecycle.State = L
const val isScrollingUpSensitivity = 10 const val isScrollingUpSensitivity = 10
@Composable @Composable
fun LazyListState.isScrollingUp(): State<Boolean> { fun LazyListState.isScrollingUp() = produceState(true, this) {
return remember(this) { var previousIndex = firstVisibleItemIndex
var previousIndex by mutableIntStateOf(firstVisibleItemIndex) var previousScrollOffset = firstVisibleItemScrollOffset
var previousScrollOffset by mutableIntStateOf(firstVisibleItemScrollOffset)
derivedStateOf { snapshotFlow {
val indexChanged = previousIndex != firstVisibleItemIndex firstVisibleItemIndex to firstVisibleItemScrollOffset
val offsetChanged = }.collect { (index, scrollOffset) ->
kotlin.math.abs(previousScrollOffset - firstVisibleItemScrollOffset) > isScrollingUpSensitivity val indexChanged = previousIndex != index
val offsetChanged = abs(previousScrollOffset - scrollOffset) > isScrollingUpSensitivity
if (indexChanged) { value = when {
previousIndex > firstVisibleItemIndex indexChanged -> previousIndex > index
} else if (offsetChanged) { offsetChanged -> previousScrollOffset > scrollOffset
previousScrollOffset > firstVisibleItemScrollOffset else -> value
} else {
true
}.also {
previousIndex = firstVisibleItemIndex
previousScrollOffset = firstVisibleItemScrollOffset
}
} }
previousIndex = index
previousScrollOffset = scrollOffset
} }
} }
// TODO: support sensitivity
@Composable @Composable
fun ScrollState.isScrollingUp(): State<Boolean> { fun ScrollState.isScrollingUp() = produceState(true, this) {
return remember(this) { var previousScrollOffset = this@isScrollingUp.value
var previousScrollOffset by mutableIntStateOf(value)
derivedStateOf { snapshotFlow { this@isScrollingUp.value }.collect { scrollOffset ->
(previousScrollOffset >= value).also { if (abs(previousScrollOffset - scrollOffset) > isScrollingUpSensitivity) {
previousScrollOffset = value value = previousScrollOffset >= scrollOffset
}
} }
previousScrollOffset = scrollOffset
} }
} }

View File

@@ -5,8 +5,8 @@
<item quantity="other">%d patches</item> <item quantity="other">%d patches</item>
</plurals> </plurals>
<plurals name="patches_executed"> <plurals name="patches_executed">
<item quantity="one">Executed %d patch</item> <item quantity="one">Execute %d patch</item>
<item quantity="other">Executed %d patches</item> <item quantity="other">Execute %d patches</item>
</plurals> </plurals>
<plurals name="selected_count"> <plurals name="selected_count">
<item quantity="other">%d selected</item> <item quantity="other">%d selected</item>

View File

@@ -1,6 +1,20 @@
<!--
Strings with new lines must be raw strings, where the string is wrapped in double quotes and new lines are regular line breaks and not \n
Raw strings still require escaping embedded double quotes, but single quote characters can be escaped or used as-is.
Raw strings are required because Crowdin AI translations regularly gets confused and
replace \n with an encoded new line character.
Bad:
<string name="summary_key">First \'item\' text\nSecond \"item\" text</string>
Good:
<string name="summary_key">"First 'item' text
Second \"item\" text"</string>
-->
<resources> <resources>
<string name="app_name">ReVanced Manager</string> <string name="app_name">ReVanced Manager</string>
<string name="patcher">Patcher</string> <string name="patcher">Patcher test</string>
<string name="patches">Patches</string> <string name="patches">Patches</string>
<string name="cli">CLI</string> <string name="cli">CLI</string>
<string name="manager">Manager</string> <string name="manager">Manager</string>
@@ -55,7 +69,7 @@
<string name="network_metered_warning">You are currently on a metered connection. Data charges from your service provider may apply.</string> <string name="network_metered_warning">You are currently on a metered connection. Data charges from your service provider may apply.</string>
<string name="apk_source_selector_item">Select APK source</string> <string name="apk_source_selector_item">Select APK source</string>
<string name="apk_source_auto">Using all APK downloader</string> <string name="apk_source_auto">Using all APK downloaders</string>
<string name="apk_source_downloader">Using %s</string> <string name="apk_source_downloader">Using %s</string>
<string name="apk_source_installed">Using installed APK</string> <string name="apk_source_installed">Using installed APK</string>
<string name="apk_source_local">Using a local APK file</string> <string name="apk_source_local">Using a local APK file</string>
@@ -69,7 +83,7 @@
<string name="auto_updates_dialog_note">These settings can be changed later.</string> <string name="auto_updates_dialog_note">These settings can be changed later.</string>
<string name="general">General</string> <string name="general">General</string>
<string name="general_description">Theme, dynamic color</string> <string name="general_description">Language, theme, dynamic color</string>
<string name="updates">Updates</string> <string name="updates">Updates</string>
<string name="updates_description">Check for updates and view changelogs</string> <string name="updates_description">Check for updates and view changelogs</string>
<string name="downloads">Downloads</string> <string name="downloads">Downloads</string>
@@ -90,19 +104,30 @@
<string name="pure_black_theme_description">Use pure black backgrounds for dark theme</string> <string name="pure_black_theme_description">Use pure black backgrounds for dark theme</string>
<string name="theme">Theme</string> <string name="theme">Theme</string>
<string name="theme_description">Choose between light or dark theme</string> <string name="theme_description">Choose between light or dark theme</string>
<string name="language">Language</string>
<string name="language_description">Choose the app display language</string>
<string name="language_system_default">System default</string>
<string name="safeguards">Safeguards</string> <string name="safeguards">Safeguards</string>
<string name="patch_compat_check">Disable version compatibility check</string> <string name="patch_compat_check">Disable version compatibility check</string>
<string name="patch_compat_check_description">The check restricts patches to compatible app versions</string> <string name="patch_compat_check_description">Do not restrict patches to compatible app versions</string>
<string name="patch_compat_check_confirmation">Selecting incompatible patches can result in a broken app.\n\nDo you want to proceed anyways?</string> <string name="patch_compat_check_confirmation">"Selecting incompatible patches can result in a broken app.
Do you want to proceed anyways?"</string>
<string name="suggested_version_safeguard">Require suggested app version</string> <string name="suggested_version_safeguard">Require suggested app version</string>
<string name="suggested_version_safeguard_description">Enforce selection of the suggested app version</string> <string name="suggested_version_safeguard_description">Enforce selection of the suggested app version</string>
<string name="suggested_version_safeguard_confirmation">Selecting an app that is not the suggested version may cause unexpected issues.\n\nDo you want to proceed anyways?</string> <string name="suggested_version_safeguard_confirmation">"Selecting an app that is not the suggested version may cause unexpected issues.
Do you want to proceed anyways?"</string>
<string name="patch_selection_safeguard">Allow changing patch selection and options</string> <string name="patch_selection_safeguard">Allow changing patch selection and options</string>
<string name="patch_selection_safeguard_description">Do not prevent selecting or deselecting patches and customization of options</string> <string name="patch_selection_safeguard_description">Do not prevent selecting or deselecting patches and customization of options</string>
<string name="patch_selection_safeguard_confirmation">Changing the selection of patches may cause unexpected issues.\n\nEnable anyways?</string> <string name="patch_selection_safeguard_confirmation">"Changing the selection of patches may cause unexpected issues.
Enable anyways?"</string>
<string name="universal_patches_safeguard">Allow using universal patches</string> <string name="universal_patches_safeguard">Allow using universal patches</string>
<string name="universal_patches_safeguard_description">Do not prevent using universal patches</string> <string name="universal_patches_safeguard_description">Do not prevent using universal patches</string>
<string name="universal_patches_safeguard_confirmation">Universal patches are not as well tested as those that target specific apps.\n\nEnable anyways?</string> <string name="universal_patches_safeguard_confirmation">"Universal patches are not as well tested as those that target specific apps.
Enable anyways?"</string>
<string name="import_keystore">Import keystore</string> <string name="import_keystore">Import keystore</string>
<string name="import_keystore_description">Import a custom keystore</string> <string name="import_keystore_description">Import a custom keystore</string>
<string name="import_keystore_dialog_title">Enter keystore credentials</string> <string name="import_keystore_dialog_title">Enter keystore credentials</string>
@@ -118,7 +143,9 @@
<string name="export_keystore_success">Exported keystore</string> <string name="export_keystore_success">Exported keystore</string>
<string name="regenerate_keystore">Regenerate keystore</string> <string name="regenerate_keystore">Regenerate keystore</string>
<string name="regenerate_keystore_description">Generate a new keystore</string> <string name="regenerate_keystore_description">Generate a new keystore</string>
<string name="regenerate_keystore_dialog_description">You are about to regenerate your keystore the manager will use during the patching process.\n\nYou will not be able to update the previously installed apps from this source.</string> <string name="regenerate_keystore_dialog_description">"You are about to regenerate your keystore the manager will use during the patching process.
You will not be able to update the previously installed apps from this source."</string>
<string name="regenerate_keystore_success">The keystore has been successfully replaced</string> <string name="regenerate_keystore_success">The keystore has been successfully replaced</string>
<string name="import_patch_selection">Import patch selection</string> <string name="import_patch_selection">Import patch selection</string>
<string name="import_patch_selection_description">Import patch selection from a JSON file</string> <string name="import_patch_selection_description">Import patch selection from a JSON file</string>
@@ -134,8 +161,8 @@
<string name="reset_patch_options_description">Reset the stored patch options</string> <string name="reset_patch_options_description">Reset the stored patch options</string>
<string name="reset_patch_selection_success">Patch selection has been reset</string> <string name="reset_patch_selection_success">Patch selection has been reset</string>
<string name="patch_selection_reset_all">Reset patch selection globally</string> <string name="patch_selection_reset_all">Reset patch selection globally</string>
<string name="patch_selection_reset_all_dialog_description">You are about to reset all the patch selections. You will need to manually select each patch again.</string> <string name="patch_selection_reset_all_dialog_description">You are about to reset all patch selections. You will need to manually select each patch again.</string>
<string name="patch_selection_reset_all_description">Resets all the patch selections</string> <string name="patch_selection_reset_all_description">Resets all patch selections</string>
<string name="patch_selection_reset_package">Reset patch selection for app</string> <string name="patch_selection_reset_package">Reset patch selection for app</string>
<string name="patch_selection_reset_package_dialog_description">You are about to reset the patch selection for the app \"%s\". You will have to manually select each patch again.</string> <string name="patch_selection_reset_package_dialog_description">You are about to reset the patch selection for the app \"%s\". You will have to manually select each patch again.</string>
<string name="patch_selection_reset_package_description">Resets patch selection for a single app</string> <string name="patch_selection_reset_package_description">Resets patch selection for a single app</string>
@@ -149,7 +176,7 @@
<string name="patch_options_reset_patches_dialog_description">You are about to reset the patch options for \"%s\". You will have to reapply each option again.</string> <string name="patch_options_reset_patches_dialog_description">You are about to reset the patch options for \"%s\". You will have to reapply each option again.</string>
<string name="patch_options_reset_patches_description">Resets the patch options for a specific collection of patches</string> <string name="patch_options_reset_patches_description">Resets the patch options for a specific collection of patches</string>
<string name="patch_options_reset_all">Reset patch options globally</string> <string name="patch_options_reset_all">Reset patch options globally</string>
<string name="patch_options_reset_all_dialog_description">You are about to reset patch options. You will have to reapply each option again.</string> <string name="patch_options_reset_all_dialog_description">You are about to reset all patch options. You will have to reapply each option again.</string>
<string name="patch_options_reset_all_description">Resets all patch options</string> <string name="patch_options_reset_all_description">Resets all patch options</string>
<string name="downloader_plugins">Plugins</string> <string name="downloader_plugins">Plugins</string>
<string name="downloader_plugin_state_trusted">Trusted</string> <string name="downloader_plugin_state_trusted">Trusted</string>
@@ -157,10 +184,12 @@
<string name="downloader_plugin_state_untrusted">Untrusted</string> <string name="downloader_plugin_state_untrusted">Untrusted</string>
<string name="downloader_plugin_trust_dialog_title">Trust plugin?</string> <string name="downloader_plugin_trust_dialog_title">Trust plugin?</string>
<string name="downloader_plugin_revoke_trust_dialog_title">Revoke trust?</string> <string name="downloader_plugin_revoke_trust_dialog_title">Revoke trust?</string>
<string name="downloader_plugin_trust_dialog_body">Package name: %1$s\nSignature (SHA-256): %2$s</string> <string name="downloader_plugin_trust_dialog_body">Continuing will allow this plugin to run on your system.\n\nOnly enable this plugin if you trust it. Plugins can execute arbitrary code and may compromise your device.</string>
<string name="downloader_plugin_trust_dialog_signature">Signature:\n\n%s</string>
<string name="downloader_plugin_trust_dialog_plugin">Plugin:\n%s</string>
<string name="downloader_plugin_delete_apps_title">Delete selected apps</string> <string name="downloader_plugin_delete_apps_title">Delete selected apps</string>
<string name="downloader_plugin_delete_apps_description">Are you sure you want to delete the selected apps?</string> <string name="downloader_plugin_delete_apps_description">Are you sure you want to delete the selected apps?</string>
<string name="downloader_settings_no_apps">No downloaded apps found</string> <string name="downloader_settings_no_apps">No downloaded apps found.</string>
<string name="search_apps">Search apps…</string> <string name="search_apps">Search apps…</string>
<string name="loading_body">Loading…</string> <string name="loading_body">Loading…</string>
@@ -191,9 +220,13 @@
<string name="light">Light</string> <string name="light">Light</string>
<string name="dark">Dark</string> <string name="dark">Dark</string>
<string name="appearance">Appearance</string> <string name="appearance">Appearance</string>
<string name="networking">Networking</string>
<string name="allow_metered_networks">Allow metered networks</string>
<string name="allow_metered_networks_description">Permits automatic updates on metered networks.
The application might still warn about metered networks for manual operations.</string>
<string name="downloaded_apps">Downloaded apps</string> <string name="downloaded_apps">Downloaded apps</string>
<string name="process_runtime">Run Patcher in another process (experimental)</string> <string name="process_runtime">Run Patcher in another process (experimental)</string>
<string name="process_runtime_description">This is faster and allows Patcher to use more memory.</string> <string name="process_runtime_description">This is faster and allows Patcher to use more memory</string>
<string name="process_runtime_memory_limit">Patcher process memory limit</string> <string name="process_runtime_memory_limit">Patcher process memory limit</string>
<string name="process_runtime_memory_limit_description">The max amount of memory that the Patcher process can use (in megabytes)</string> <string name="process_runtime_memory_limit_description">The max amount of memory that the Patcher process can use (in megabytes)</string>
<string name="debug_logs_export">Export debug logs</string> <string name="debug_logs_export">Export debug logs</string>
@@ -201,7 +234,7 @@
<string name="debug_logs_export_failed">Failed to export logs</string> <string name="debug_logs_export_failed">Failed to export logs</string>
<string name="debug_logs_export_success">Exported logs</string> <string name="debug_logs_export_success">Exported logs</string>
<string name="api_url">API URL</string> <string name="api_url">API URL</string>
<string name="api_url_description">The API used to download necessary files.</string> <string name="api_url_description">The API used to download necessary files</string>
<string name="api_url_dialog_title">Change API URL</string> <string name="api_url_dialog_title">Change API URL</string>
<string name="api_url_dialog_description">Change the API URL of ReVanced Manager. ReVanced Manager uses the API to download patches and updates.</string> <string name="api_url_dialog_description">Change the API URL of ReVanced Manager. ReVanced Manager uses the API to download patches and updates.</string>
<string name="api_url_dialog_warning">ReVanced Manager connects to the API to download patches and updates. Make sure that you trust it.</string> <string name="api_url_dialog_warning">ReVanced Manager connects to the API to download patches and updates. Make sure that you trust it.</string>
@@ -237,14 +270,23 @@
<string name="patch_selection_reset_toast">Patch selection and options has been reset to recommended defaults</string> <string name="patch_selection_reset_toast">Patch selection and options has been reset to recommended defaults</string>
<string name="patch_options_reset_toast">Patch options have been reset</string> <string name="patch_options_reset_toast">Patch options have been reset</string>
<string name="non_suggested_version_warning_title">Non suggested version</string> <string name="non_suggested_version_warning_title">Non suggested version</string>
<string name="non_suggested_version_warning_description">The version of the app you have selected does not match the suggested version.\nPlease use the suggested version: %s\n\nTo continue anyway, disable \"Require suggested app version\" in the advanced settings.</string> <string name="non_suggested_version_warning_description">"The version of the app you have selected does not match the suggested version.
Please use the suggested version: %s
To continue anyway, disable \"Require suggested app version\" in the advanced settings."</string>
<string name="selection_warning_title">Stop using defaults?</string> <string name="selection_warning_title">Stop using defaults?</string>
<string name="selection_warning_description">It is recommended to use the default patch selection and options. Changing them may result in unexpected issues.\n\nYou need to turn on \"Allow changing patch selection and options\" in the advanced settings before toggling patches.</string> <string name="selection_warning_description">"It is recommended to use the default patch selection and options. Changing them may result in unexpected issues.
<string name="universal_patch_warning_description">Universal patches have a more generalized use and do not work as reliably as patches that target specific apps. You may encounter issues while using them.\n\nYou need to turn on \"Allow using universal patches\" in the advanced settings before using universal patches.</string>
You need to turn on \"Allow changing patch selection and options\" in the advanced settings before toggling patches."</string>
<string name="universal_patch_warning_description">"Universal patches have a more generalized use and do not work as reliably as patches that target specific apps. You may encounter issues while using them.
You need to turn on \"Allow using universal patches\" in the advanced settings before using universal patches."</string>
<string name="this_version">This version</string> <string name="this_version">This version</string>
<string name="universal">Any app</string> <string name="universal">Any app</string>
<string name="search_patches">Search patches</string> <string name="search_patches">Search patches</string>
<string name="app_version_not_compatible">This patch is not compatible with the selected app version (%1$s).\n\nIt is only compatible with the following version(s): %2$s.</string> <string name="app_version_not_compatible">"This patch is not compatible with the selected app version (%1$s)
It is only compatible with the following version(s): %2$s"</string>
<string name="continue_with_version">Continue with this version?</string> <string name="continue_with_version">Continue with this version?</string>
<string name="version_not_compatible">Not all patches are compatible with this version (%s). Do you want to continue anyway?</string> <string name="version_not_compatible">Not all patches are compatible with this version (%s). Do you want to continue anyway?</string>
<string name="download_application">Download application?</string> <string name="download_application">Download application?</string>
@@ -278,7 +320,7 @@
<string name="downloader_app_not_found">Downloader did not find the app</string> <string name="downloader_app_not_found">Downloader did not find the app</string>
<string name="downloader_error">Downloader error: %s</string> <string name="downloader_error">Downloader error: %s</string>
<string name="downloader_no_plugins_installed">No downloader installed.</string> <string name="downloader_no_plugins_installed">No downloader installed.</string>
<string name="downloader_no_plugins_available">There are downloader installed but none is trusted. Check your settings.</string> <string name="downloader_no_plugins_available">There are downloaders installed but none are trusted. Check your settings.</string>
<string name="already_patched">Already patched</string> <string name="already_patched">Already patched</string>
<string name="patch_selector_sheet_filter_title">Filter</string> <string name="patch_selector_sheet_filter_title">Filter</string>
@@ -386,7 +428,8 @@
<string name="save_with_count">Save (%1$s)</string> <string name="save_with_count">Save (%1$s)</string>
<string name="update">Update</string> <string name="update">Update</string>
<string name="empty">Empty</string> <string name="empty">Empty</string>
<string name="installing_message">Tap on <b>Update</b> when prompted.\nReVanced Manager will close when updating.</string> <string name="installing_message">"Tap on <b>Update</b> when prompted.
ReVanced Manager will close when updating."</string>
<string name="no_changelogs_found">No changelogs found</string> <string name="no_changelogs_found">No changelogs found</string>
<string name="just_now">Just now</string> <string name="just_now">Just now</string>
<string name="minutes_ago">%sm ago</string> <string name="minutes_ago">%sm ago</string>
@@ -405,7 +448,9 @@
<string name="update_available_dialog_description">A new version of ReVanced Manager (%s) is available.</string> <string name="update_available_dialog_description">A new version of ReVanced Manager (%s) is available.</string>
<string name="failed_to_download_update">Failed to download update: %s</string> <string name="failed_to_download_update">Failed to download update: %s</string>
<string name="download">Download</string> <string name="download">Download</string>
<string name="download_confirmation_metered">You are currently on a metered connection, and data charges from your service provider may apply.\n\nDo you still want to continue?</string> <string name="download_confirmation_metered">"You are currently on a metered connection, and data charges from your service provider may apply.
Do you still want to continue?"</string>
<string name="download_update_confirmation">Download update?</string> <string name="download_update_confirmation">Download update?</string>
<string name="no_contributors_found">No contributors found</string> <string name="no_contributors_found">No contributors found</string>
<string name="select">Select</string> <string name="select">Select</string>
@@ -443,12 +488,14 @@
<string name="patches_prereleases">Use pre-releases</string> <string name="patches_prereleases">Use pre-releases</string>
<string name="patches_prereleases_description">Use pre-release versions of %s</string> <string name="patches_prereleases_description">Use pre-release versions of %s</string>
<string name="patches_url">Patches URL</string> <string name="patches_url">Patches URL</string>
<string name="incompatible_patches_dialog">These patches are not compatible with the selected app version (%1$s).\n\nClick on the patches to see more details.</string> <string name="incompatible_patches_dialog">"These patches are not compatible with the selected app version (%1$s).
Click on the patches to see more details."</string>
<string name="incompatible_patch">Incompatible patch</string> <string name="incompatible_patch">Incompatible patch</string>
<string name="any_version">Any</string> <string name="any_version">Any</string>
<string name="never_show_again">Never show again</string> <string name="never_show_again">Never show again</string>
<string name="show_manager_update_dialog_on_launch">Show update message on launch</string> <string name="show_manager_update_dialog_on_launch">Show update message on launch</string>
<string name="show_manager_update_dialog_on_launch_description">Shows a popup notification whenever there is a new update available on launch.</string> <string name="show_manager_update_dialog_on_launch_description">Show a popup notification whenever a new update is available on launch</string>
<string name="failed_to_import_keystore">Failed to import keystore</string> <string name="failed_to_import_keystore">Failed to import keystore</string>
<string name="export">Export</string> <string name="export">Export</string>
<string name="confirm">Confirm</string> <string name="confirm">Confirm</string>

View File

@@ -6,5 +6,6 @@ plugins {
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.about.libraries) apply false alias(libs.plugins.about.libraries) apply false
alias(libs.plugins.about.libraries.android) apply false
alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.compose.compiler) apply false
} }

9
crowdin.yml Normal file
View File

@@ -0,0 +1,9 @@
project_id_env: "CROWDIN_PROJECT_ID"
api_token_env: "CROWDIN_PERSONAL_TOKEN"
preserve_hierarchy: true
files:
- source: app/src/main/res/values/strings.xml
dest: manager.xml
translation: app/src/main/res/values-%android_code%/strings.xml
skip_untranslated_strings: true

View File

@@ -1,32 +1,32 @@
[versions] [versions]
ktx = "1.16.0" ktx = "1.17.0"
material3 = "1.3.2" material3 = "1.4.0"
ui-tooling = "1.8.1" ui-tooling = "1.10.0"
viewmodel-lifecycle = "2.9.0" viewmodel-lifecycle = "2.10.0"
splash-screen = "1.0.1" splash-screen = "1.2.0"
activity = "1.10.1" activity = "1.12.2"
appcompat = "1.7.0" appcompat = "1.7.1"
preferences-datastore = "1.1.2" preferences-datastore = "1.2.0"
work-runtime = "2.10.1" work-runtime = "2.11.0"
compose-bom = "2025.05.00" compose-bom = "2025.12.01"
navigation = "2.8.6" navigation = "2.9.6"
accompanist = "0.37.0" accompanist = "0.37.3"
placeholder = "1.1.2" placeholder = "1.0.12"
reorderable = "2.4.3" reorderable = "3.0.0"
serialization = "1.8.0" serialization = "1.9.0"
collection = "0.3.8" collection = "0.4.0"
datetime = "0.6.1" datetime = "0.7.1"
room-version = "2.7.1" room-version = "2.8.4"
revanced-patcher = "21.0.0" revanced-patcher = "21.0.0"
revanced-library = "3.0.2" revanced-library = "3.0.2"
koin = "3.5.3" koin = "4.1.1"
ktor = "2.3.9" ktor = "3.3.3"
markdown-renderer = "0.30.0" markdown-renderer = "0.39.0"
fading-edges = "1.0.4" fading-edges = "1.0.4"
kotlin = "2.1.10" kotlin = "2.3.0"
android-gradle-plugin = "8.9.1" android-gradle-plugin = "8.13.2"
dev-tools-gradle-plugin = "2.1.10-1.0.29" dev-tools-gradle-plugin = "2.3.4"
about-libraries-gradle-plugin = "12.1.2" about-libraries = "13.2.1"
coil = "2.7.0" coil = "2.7.0"
app-icon-loader-coil = "1.5.0" app-icon-loader-coil = "1.5.0"
libsu = "6.0.0" libsu = "6.0.0"
@@ -34,9 +34,10 @@ scrollbars = "1.0.4"
enumutil = "1.1.1" enumutil = "1.1.1"
compose-icons = "1.2.4" compose-icons = "1.2.4"
kotlin-process = "1.5.1" kotlin-process = "1.5.1"
hidden-api-stub = "4.3.3" hidden-api-stub = "4.4.0"
binary-compatibility-validator = "0.17.0" binary-compatibility-validator = "0.18.1"
semver-parser = "3.0.0" semver-parser = "3.0.0"
ackpine = "0.19.1"
[libraries] [libraries]
# AndroidX Core # AndroidX Core
@@ -67,7 +68,7 @@ coil-appiconloader = { group = "me.zhanghai.android.appiconloader", name = "appi
accompanist-drawablepainter = { group = "com.google.accompanist", name = "accompanist-drawablepainter", version.ref = "accompanist" } accompanist-drawablepainter = { group = "com.google.accompanist", name = "accompanist-drawablepainter", version.ref = "accompanist" }
# Placeholder # Placeholder
placeholder-material3 = { group = "io.github.fornewid", name = "placeholder-material3", version.ref = "placeholder"} placeholder-material3 = { group = "com.eygraber", name = "compose-placeholder-material3", version.ref = "placeholder" }
# Kotlinx # Kotlinx
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
@@ -90,7 +91,8 @@ koin-compose-navigation = { group = "io.insert-koin", name = "koin-androidx-comp
koin-workmanager = { group = "io.insert-koin", name = "koin-androidx-workmanager", version.ref = "koin" } koin-workmanager = { group = "io.insert-koin", name = "koin-androidx-workmanager", version.ref = "koin" }
# About Libraries # About Libraries
about-libraries = { group = "com.mikepenz", name = "aboutlibraries-compose", version.ref = "about-libraries-gradle-plugin" } about-libraries-core = { group = "com.mikepenz", name = "aboutlibraries-compose-core", version.ref = "about-libraries" }
about-libraries-m3 = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "about-libraries" }
# Ktor # Ktor
ktor-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } ktor-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
@@ -133,6 +135,10 @@ compose-icons-fontawesome = { group = "com.github.BenjaminHalko.compose-icons",
# Semantic versioning parser # Semantic versioning parser
semver-parser = { module = "io.github.z4kn4fein:semver", version.ref = "semver-parser" } semver-parser = { module = "io.github.z4kn4fein:semver", version.ref = "semver-parser" }
# Ackpine
ackpine-core = { module = "ru.solrudev.ackpine:ackpine-core", version.ref = "ackpine" }
ackpine-ktx = { module = "ru.solrudev.ackpine:ackpine-ktx", version.ref = "ackpine" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }
android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" }
@@ -141,5 +147,6 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
devtools = { id = "com.google.devtools.ksp", version.ref = "dev-tools-gradle-plugin" } devtools = { id = "com.google.devtools.ksp", version.ref = "dev-tools-gradle-plugin" }
about-libraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "about-libraries-gradle-plugin" } about-libraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "about-libraries" }
about-libraries-android = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "about-libraries" }
binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" } binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" }