diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c2c235e..1b1999f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,6 +7,8 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.ksp) alias(libs.plugins.compose.compiler) + alias(libs.plugins.serialization) + id("com.mikepenz.aboutlibraries.plugin") } val gitCommitCount: Int by lazy { runGitCommand("rev-list", "--count", "HEAD")?.toIntOrNull() ?: 0 } @@ -42,6 +44,7 @@ android { versionName = gropify.project.app.versionName versionCode = gitVersionCode testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "BUILD_CHANNEL", "\"$buildSuffix\"") } signingConfigs { @@ -120,6 +123,41 @@ androidComponents { } } +aboutLibraries { + offlineMode = false + collect { + configPath.file("config") // TODO(ASAP) libraries json ignored + fetchRemoteLicense.set(false) + } + export { + // Remove the "generated" timestamp to allow for reproducible builds + prettyPrint.set(true) + } + license { + // TODO https://github.com/mikepenz/AboutLibraries/issues/1190 + strictMode = com.mikepenz.aboutlibraries.plugin.StrictMode.FAIL + allowedLicensesMap.put("Other", listOf("com.github.bumptech.glide:glide")) + allowedLicenses.addAll( + "Apache-2.0", + "LGPL", + "GNU Lesser General Public License v2.1", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + "MIT", + "EPL-1.0", + "GPL-3.0-only", + "GNU Lesser General Public License v3.0" + ) + } + library { + // Enable the duplication mode, allows to merge, or link dependencies which relate + duplicationMode.set(com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE) + // Configure the duplication rule, to match "duplicates" with + duplicationRule.set(com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP) + } +} + tasks.withType().configureEach { compilerOptions { jvmTarget = JvmTarget.JVM_17 @@ -157,6 +195,12 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.compose.icons.material.symbols.outlined.cmp) + implementation(libs.compose.icons.material.symbols.rounded.cmp) + implementation(libs.compose.icons.material.symbols.sharp.cmp) + implementation(libs.compose.icons.material.symbols.outlined.filled.cmp) + implementation(libs.compose.icons.material.symbols.rounded.filled.cmp) + implementation(libs.compose.icons.material.symbols.sharp.filled.cmp) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.miuix.ui) @@ -173,4 +217,5 @@ dependencies { implementation(libs.lyricon.central) implementation(libs.lyricon.subscriber) implementation(libs.superlyric) + implementation(libs.kotlinx.serialization.json) } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a9af6e3..a3b5b75 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -39,5 +39,6 @@ # --- Tool --- -keep class hk.uwu.reareye.hook.** { *; } +-keep class hk.uwu.reareye.utils.other.AboutLibrariesToolsKt -keep class com.hchen.superlyricapi.* {*;} -dontwarn android.os.ServiceManager \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cdd9e1d..bcb5ede 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -60,6 +60,12 @@ + + + + + + + diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/Scope.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/Scope.kt index 8e6c1dd..be438cb 100644 --- a/app/src/main/java/hk/uwu/reareye/hook/scopes/Scope.kt +++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/Scope.kt @@ -1,7 +1,18 @@ package hk.uwu.reareye.hook.scopes +import com.highcapable.kavaref.KavaRef.Companion.resolve +import com.highcapable.kavaref.extension.toClass import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker interface Scope { val hooks: List + val isRearDevice + get() = Companion.isRearDevice + + companion object { + val isRearDevice: Boolean = "android.os.SystemProperties".toClass().resolve().firstMethod { + name = "getInt" + parameters(String::class.java, Int::class.java) + }.invoke("persist.sys.multi_display_type", 1) == 6 + } } \ No newline at end of file diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/SubscreenCenterScope.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/SubscreenCenterScope.kt index 1922193..76ecb12 100644 --- a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/SubscreenCenterScope.kt +++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/SubscreenCenterScope.kt @@ -1,9 +1,11 @@ package hk.uwu.reareye.hook.scopes.subscreencenter import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker +import com.highcapable.yukihookapi.hook.log.YLog import hk.uwu.reareye.hook.scopes.Scope import hk.uwu.reareye.hook.scopes.subscreencenter.modules.MusicControlWhitelistModule import hk.uwu.reareye.hook.scopes.subscreencenter.modules.RearWallpaperHook +import hk.uwu.reareye.hook.scopes.subscreencenter.modules.SubScreenBackHomeWhitelistModule import hk.uwu.reareye.hook.scopes.subscreencenter.modules.VideoLoopModule import hk.uwu.reareye.hook.scopes.subscreencenter.modules.VideoVolumeHook import hk.uwu.reareye.hook.scopes.subscreencenter.modules.lyrics.LyriconHook @@ -12,14 +14,23 @@ import hk.uwu.reareye.hook.scopes.subscreencenter.modules.rearwidget.RearWidgetH import hk.uwu.reareye.hook.scopes.subscreencenter.modules.rearwidget.SystemUiNotificationBridgeHook class SubscreenCenterScope : Scope { - override val hooks: List = listOf( - MusicControlWhitelistModule(), - VideoLoopModule(), - RearWallpaperHook(), - RearWidgetHook(), - SystemUiNotificationBridgeHook(), - LyriconHook(), - VideoVolumeHook(), - ExtraTimeTipHook() - ) + override val hooks: List = buildList { + if (isRearDevice) { + addAll( + listOf( + MusicControlWhitelistModule(), + SubScreenBackHomeWhitelistModule(), + VideoLoopModule(), + RearWallpaperHook(), + RearWidgetHook(), + SystemUiNotificationBridgeHook(), + LyriconHook(), + VideoVolumeHook(), + ExtraTimeTipHook() + ) + ) + } else { + YLog.debug("This device is not support rear screen, skip load some features that this device is not supported") + } + } } diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/RearWallpaperHook.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/RearWallpaperHook.kt index 1a292f1..9a985c4 100644 --- a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/RearWallpaperHook.kt +++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/RearWallpaperHook.kt @@ -263,73 +263,73 @@ class RearWallpaperHook : YukiBaseHooker() { YLog.warn(it) } } - val saveSelectionPoint = - resolveMainPanelSaveSelectionMethod(bridge) - resolveLauncherMainPanelFieldName() - resolveLauncherMainHandlerFieldName() - resolveMainPanelSelectMethod() - resolveMainPanelEditModeFieldName() - resolveMainPanelResumedFieldName() - resolveMainPanelAodFieldName() - resolveMainPanelSelectedIndexFieldName() - resolveMainPanelWidgetListFieldName() - resolveWidgetFactoryMethod() - resolveWallpaperSpecIdFieldName() - resolveWallpaperSpecExtrasFieldName() - resolveWidgetIdFieldName() - resolveWidgetSpecFieldName() - resolveWidgetExtrasFieldName() - resolveWidgetHostFieldName() - resolveWidgetPreviewModeFieldName() - resolveWidgetSetEditModeMethod() - resolveWidgetCreateViewMethod() - resolveWidgetSetAodMethod() - resolveWidgetResumeMethod() - resolveWidgetCleanupMethod() - resolvePrefStoreClass() - resolvePrefStoreInstanceFieldName() - resolvePrefStoreLoadSpecsMethod() - resolvePrefStoreReadValueMethod() - resolvePrefStoreWriteValueMethod() - resolveWallpaperRuntimeListMethod() - resolveDeviceConfigClass() - resolveDeviceConfigRenderSizeFieldName() - resolveDeviceConfigLocaleSuffixFieldName() - - launcherRef.firstMethod { - name = "onResume" - parameterCount = 0 - }.hook().after { - runCatching { - capturePanels(instance) - refreshSchedule(forceApply = true) - }.onFailure { - YLog.warn(it) - } - } + val saveSelectionPoint = + resolveMainPanelSaveSelectionMethod(bridge) + resolveLauncherMainPanelFieldName() + resolveLauncherMainHandlerFieldName() + resolveMainPanelSelectMethod() + resolveMainPanelEditModeFieldName() + resolveMainPanelResumedFieldName() + resolveMainPanelAodFieldName() + resolveMainPanelSelectedIndexFieldName() + resolveMainPanelWidgetListFieldName() + resolveWidgetFactoryMethod() + resolveWallpaperSpecIdFieldName() + resolveWallpaperSpecExtrasFieldName() + resolveWidgetIdFieldName() + resolveWidgetSpecFieldName() + resolveWidgetExtrasFieldName() + resolveWidgetHostFieldName() + resolveWidgetPreviewModeFieldName() + resolveWidgetSetEditModeMethod() + resolveWidgetCreateViewMethod() + resolveWidgetSetAodMethod() + resolveWidgetResumeMethod() + resolveWidgetCleanupMethod() + resolvePrefStoreClass() + resolvePrefStoreInstanceFieldName() + resolvePrefStoreLoadSpecsMethod() + resolvePrefStoreReadValueMethod() + resolvePrefStoreWriteValueMethod() + resolveWallpaperRuntimeListMethod() + resolveDeviceConfigClass() + resolveDeviceConfigRenderSizeFieldName() + resolveDeviceConfigLocaleSuffixFieldName() - launcherRef.firstMethod { - name = "onPause" - parameterCount = 0 - }.hook().before { - debugLog("launcher onPause keep scheduler nextAt=${readNextSwitchAt()}") - } + launcherRef.firstMethod { + name = "onResume" + parameterCount = 0 + }.hook().after { + runCatching { + capturePanels(instance) + refreshSchedule(forceApply = true) + }.onFailure { + YLog.warn(it) + } + } - launcherRef.firstMethod { - name = "onDestroy" - parameterCount = 0 - }.hook().before { - stopScheduler() - mainPanel = null - mainHandler = null - } + launcherRef.firstMethod { + name = "onPause" + parameterCount = 0 + }.hook().before { + debugLog("launcher onPause keep scheduler nextAt=${readNextSwitchAt()}") + } - saveSelectionPoint.className.toClass().resolve().firstMethod { - name = saveSelectionPoint.methodName - parameterCount = 0 - }.hook().after { - updateSelectedWallpaperIdFromPanel(instance) - } + launcherRef.firstMethod { + name = "onDestroy" + parameterCount = 0 + }.hook().before { + stopScheduler() + mainPanel = null + mainHandler = null + } + + saveSelectionPoint.className.toClass().resolve().firstMethod { + name = saveSelectionPoint.methodName + parameterCount = 0 + }.hook().after { + updateSelectedWallpaperIdFromPanel(instance) + } } } diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/SubScreenBackHomeWhitelistModule.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/SubScreenBackHomeWhitelistModule.kt new file mode 100644 index 0000000..bacef58 --- /dev/null +++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/SubScreenBackHomeWhitelistModule.kt @@ -0,0 +1,131 @@ +package hk.uwu.reareye.hook.scopes.subscreencenter.modules + +import com.highcapable.kavaref.KavaRef.Companion.asResolver +import com.highcapable.kavaref.KavaRef.Companion.resolve +import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker +import com.highcapable.yukihookapi.hook.log.YLog +import hk.uwu.reareye.hook.utils.DexKitMethodInjectionPoint +import hk.uwu.reareye.hook.utils.createDexKitCacheBridge +import hk.uwu.reareye.hook.utils.resolveDexKitFieldValue +import hk.uwu.reareye.hook.utils.resolveDexKitMethodInjectionPoint +import hk.uwu.reareye.hook.utils.resolveHookPackageVersionCode +import hk.uwu.reareye.ui.config.ConfigKeys +import org.luckypray.dexkit.DexKitCacheBridge +import org.luckypray.dexkit.annotations.DexKitExperimentalApi + +@OptIn(DexKitExperimentalApi::class) +class SubScreenBackHomeWhitelistModule : YukiBaseHooker() { + companion object { + private const val SUBSCREEN_HOME_TO_FRONT_METHOD_CACHE_KEY = + "SSC_BACK_HOME_HOME_TO_FRONT_METHOD" + private const val SUBSCREEN_FOREGROUND_PACKAGE_FIELD_CACHE_KEY = + "SSC_BACK_HOME_FOREGROUND_PACKAGE_FIELD" + private const val AOD_REASON = "aod" + } + + override fun onHook() { + loadApp("com.xiaomi.subscreencenter") { + val versionCode = resolveHookPackageVersionCode( + systemContext, + appInfo.packageName, + appInfo.sourceDir, + ) + val bridge = createDexKitCacheBridge( + packageName = appInfo.packageName, + packageVersionCode = versionCode, + sourceDir = appInfo.sourceDir, + dataDir = appInfo.dataDir, + ) + val homeToFrontPoint = resolveSubScreenHomeToFrontMethod(bridge) + val foregroundPackageFieldName = resolveForegroundPackageFieldName( + bridge = bridge, + homeToFrontPoint = homeToFrontPoint, + ) + + homeToFrontPoint.className.toClass().resolve().firstMethod { + name = homeToFrontPoint.methodName + returnType = Void.TYPE + parameters(String::class.java) + }.hook().replaceUnit { + val reason = args(0).cast() + val whitelist = prefs.getStringSet( + ConfigKeys.SUBSCREEN_LOCK_BACK_HOME_WHITELIST_APPS, + ) + if (reason != AOD_REASON || whitelist.isEmpty()) { + invokeOriginal(*args) + return@replaceUnit + } + + val foregroundPackage = instance.asResolver().firstField { + name = foregroundPackageFieldName + type = String::class.java + }.get() + val moreDebug = prefs.getBoolean(ConfigKeys.MORE_DEBUG, false) + if (moreDebug) { + YLog.debug( + "Handle subscreen home return reason=$reason package=$foregroundPackage", + ) + } + if (foregroundPackage == null || foregroundPackage !in whitelist) { + invokeOriginal(*args) + return@replaceUnit + } + + if (moreDebug) { + YLog.debug( + "Skip SubScreen home return reason=$reason package=$foregroundPackage", + ) + } + } + } + } + + private fun resolveSubScreenHomeToFrontMethod( + bridge: DexKitCacheBridge.RecyclableBridge, + ): DexKitMethodInjectionPoint { + return resolveDexKitMethodInjectionPoint( + bridge = bridge, + cacheKey = SUBSCREEN_HOME_TO_FRONT_METHOD_CACHE_KEY, + ) { + // SubScreenCenterApp.e(String) pulls SubScreenLauncher to display 1 for AOD. + findMethod { + searchPackages("com.xiaomi.subscreencenter") + matcher { + paramTypes(String::class.java) + returnType = "void" + usingStrings( + "Start SubScreen Home reason ", + "getHomeToFrontOptions from Aod or turning off", + ) + } + }.singleOrNull() + } ?: error("DexKit failed to resolve SubScreen home-to-front method") + } + + private fun resolveForegroundPackageFieldName( + bridge: DexKitCacheBridge.RecyclableBridge, + homeToFrontPoint: DexKitMethodInjectionPoint, + ): String { + return resolveDexKitFieldValue( + bridge = bridge, + cacheKey = SUBSCREEN_FOREGROUND_PACKAGE_FIELD_CACHE_KEY, + ) { + findField { + searchPackages(homeToFrontPoint.className.substringBeforeLast('.')) + matcher { + declaredClass = homeToFrontPoint.className + type = "java.lang.String" + readMethods { + add { + declaredClass = homeToFrontPoint.className + name = homeToFrontPoint.methodName + paramTypes(String::class.java) + returnType = "void" + usingStrings("Start SubScreen Home reason ") + } + } + } + }.singleOrNull() + } ?: error("DexKit failed to resolve SubScreen foreground package field") + } +} diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/rearwidget/RearWidgetHook.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/rearwidget/RearWidgetHook.kt index fdb5aef..b9bf6b8 100644 --- a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/rearwidget/RearWidgetHook.kt +++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/rearwidget/RearWidgetHook.kt @@ -2052,14 +2052,14 @@ class RearWidgetHook : YukiBaseHooker() { val extras = RearWidgetRuntimeStore.buildDecoratedExtras(notice.ticket) val runnable = resolveSmartAssistantPostRunnableClassName().toClass().resolve().firstConstructor { - parameterCount = 5 - }.create( - mgr, - notice.ticket.notificationId, - notice.ticket.packageName, - notice.ticket.compositeKey, - extras, - ) as? Runnable ?: return + parameterCount = 5 + }.create( + mgr, + notice.ticket.notificationId, + notice.ticket.packageName, + notice.ticket.compositeKey, + extras, + ) as? Runnable ?: return handler.post(runnable) debugLog("injected ticket key=${notice.ticket.compositeKey} business=${notice.ticket.business}") }.onFailure { diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/system/SystemScope.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/SystemScope.kt index 8d31668..51a583c 100644 --- a/app/src/main/java/hk/uwu/reareye/hook/scopes/system/SystemScope.kt +++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/SystemScope.kt @@ -1,18 +1,35 @@ package hk.uwu.reareye.hook.scopes.system import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker +import com.highcapable.yukihookapi.hook.log.YLog import hk.uwu.reareye.hook.scopes.Scope import hk.uwu.reareye.hook.scopes.system.modules.BackgroundWhitelistModule +import hk.uwu.reareye.hook.scopes.system.modules.CustomBoundsCompatModule import hk.uwu.reareye.hook.scopes.system.modules.DisableRearScreenCoverHook +import hk.uwu.reareye.hook.scopes.system.modules.DisableSubScreenDoubleTapSleepHook +import hk.uwu.reareye.hook.scopes.system.modules.DisableSubScreenDoubleTapWakeHook +import hk.uwu.reareye.hook.scopes.system.modules.DisableSubScreenHighLoadModeHook import hk.uwu.reareye.hook.scopes.system.modules.RearScreenActivityWhitelistModule import hk.uwu.reareye.hook.scopes.system.modules.misc.GMSUnlockModule class SystemScope : Scope { - override val hooks: List = listOf( - RearScreenActivityWhitelistModule(), - BackgroundWhitelistModule(), - GMSUnlockModule(), - DisableRearScreenCoverHook() - ) -} \ No newline at end of file + override val hooks: List = buildList { + add(GMSUnlockModule()) + add(CustomBoundsCompatModule()) + if (isRearDevice) { + addAll( + listOf( + RearScreenActivityWhitelistModule(), + BackgroundWhitelistModule(), + DisableRearScreenCoverHook(), + DisableSubScreenDoubleTapSleepHook(), + DisableSubScreenDoubleTapWakeHook(), + DisableSubScreenHighLoadModeHook(), + ) + ) + } else { + YLog.debug("This device is not support rear screen, skip load some features that this device is not supported") + } + } +} diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/CustomBoundsCompatModule.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/CustomBoundsCompatModule.kt new file mode 100644 index 0000000..03b39e5 --- /dev/null +++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/CustomBoundsCompatModule.kt @@ -0,0 +1,579 @@ +package hk.uwu.reareye.hook.scopes.system.modules + +import android.content.Context +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.Point +import android.graphics.Rect +import com.highcapable.kavaref.KavaRef.Companion.asResolver +import com.highcapable.kavaref.KavaRef.Companion.resolve +import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker +import com.highcapable.yukihookapi.hook.log.YLog +import hk.uwu.reareye.BuildConfig +import hk.uwu.reareye.repository.bounds.CustomBoundsCompatAppConfig +import hk.uwu.reareye.repository.bounds.CustomBoundsCompatConfigCodec +import hk.uwu.reareye.repository.bounds.CustomBoundsFillMode +import hk.uwu.reareye.repository.bounds.CustomBoundsMode +import hk.uwu.reareye.ui.config.ConfigKeys +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +class CustomBoundsCompatModule : YukiBaseHooker() { + override fun onHook() { + loadSystem { + val activityRecordImplClass = runCatching { + "com.android.server.wm.ActivityRecordImpl".toClass() + }.getOrElse { + YLog.warn("ActivityRecordImpl class not found, skip custom rear bounds hook") + return@loadSystem + } + val activityRecordImplRef = runCatching { + activityRecordImplClass.resolve() + }.getOrElse { + YLog.warn("ActivityRecordImpl not found, skip custom rear bounds hook") + return@loadSystem + } + + val themeColorUtilsClass = "com.android.server.wm.ThemeColorUtils".toClass() + + activityRecordImplRef.firstMethod { + name = "resolveOverrideConfiguration" + parameterCount = 2 + }.hook().after { + val moreDebug = prefs.getBoolean(ConfigKeys.MORE_DEBUG, false) + val parentConfig = args(0).cast() ?: return@after + val resolvedConfig = args(1).cast() ?: return@after + val activityRecord = instance.field("mAr") ?: run { + if (moreDebug) YLog.debug("[$TAG] skip reason=no_activity_record") + return@after + } + val packageName = activityRecord.field("packageName") ?: run { + if (moreDebug) YLog.debug("[$TAG] skip reason=no_package") + return@after + } + val config = CustomBoundsCompatHookConfig.find( + raw = prefs.getString( + ConfigKeys.CUSTOM_BOUNDS_COMPAT_CONFIG_DATA, + CustomBoundsCompatConfigCodec.EMPTY_ARRAY, + ), + packageName = packageName, + ) ?: run { + if (moreDebug) YLog.debug("[$TAG] skip package=$packageName reason=no_config") + return@after + } + if (!config.enabled) { + if (moreDebug) YLog.debug("[$TAG] skip package=$packageName reason=disabled") + return@after + } + + val displayId = activityRecord.call("getDisplayId") ?: -1 + if (displayId != TARGET_DISPLAY_ID) { + if (moreDebug) YLog.debug("[$TAG] skip package=$packageName reason=display_id displayId=$displayId") + return@after + } + if (activityRecord.call("inMultiWindowMode") == true) { + if (moreDebug) YLog.debug("[$TAG] skip package=$packageName reason=multi_window") + return@after + } + + val parentBounds = getWindowConfigurationBounds(parentConfig) + if (parentBounds == null || parentBounds.isEmpty) { + if (moreDebug) YLog.debug("[$TAG] skip package=$packageName reason=empty_parent_bounds bounds=$parentBounds") + return@after + } + + val compatBounds = when (config.mode) { + CustomBoundsMode.EXACT_INSETS -> computeInsetsBounds(parentBounds, config) + CustomBoundsMode.CUSTOM_RATIO -> computeCompatBounds( + parentBounds = parentBounds, + aspectRatio = config.aspectRatio, + gravity = config.gravity, + scale = config.scale, + ) + + CustomBoundsMode.AUTO_RATIO -> computeCompatBounds( + parentBounds = parentBounds, + aspectRatio = CustomBoundsCompatConfigCodec.defaultAutoRatio( + parentBounds.width(), + parentBounds.height(), + ), + gravity = config.gravity, + scale = config.scale, + ) + } + applyResolvedConfiguration( + config = resolvedConfig, + bounds = compatBounds, + densityDpi = config.densityDpi.takeIf { it > 0 } ?: parentConfig.densityDpi, + rotation = config.rotationDegrees, + ) + @Suppress("SimplifyBooleanWithConstants", "KotlinConstantConditions") + prepareFlipSplashColor( + activityRecordImpl = instance, + activityRecord = activityRecord, + bounds = compatBounds, + moreDebug = moreDebug, + collectDetails = moreDebug && SHOULD_LOG_DETAILS, + themeColorUtilsClass = themeColorUtilsClass + ) + applyTaskFillColor( + activityRecordImpl = instance, + activityRecord = activityRecord, + config = config, + moreDebug = moreDebug, + ) + if (moreDebug) { + YLog.debug( + "[$TAG] apply package=$packageName displayId=$displayId parent=$parentBounds bounds=$compatBounds mode=${config.mode} ratio=${config.aspectRatio} insets=${config.insetLeft},${config.insetTop},${config.insetRight},${config.insetBottom} gravity=${config.gravity} scale=${config.scale} dpi=${config.densityDpi} rotation=${config.rotationDegrees} fill=${config.fillEnabled}/${config.fillMode}" + ) + } + } + } + } + + private fun prepareFlipSplashColor( + activityRecordImpl: Any?, + activityRecord: Any?, + bounds: Rect, + moreDebug: Boolean, + collectDetails: Boolean, + themeColorUtilsClass: Class<*>? + ) { + val activityInfo = activityRecord.field("info") ?: return + val theme = activityRecord.call("getTheme") + ?: activityInfo.themeResource + val splashColor = computeSplashColorDirect( + activityRecordImpl = activityRecordImpl, + activityRecord = activityRecord, + activityInfo = activityInfo, + theme = theme, + collectDetails = collectDetails, + themeColorUtilsClass = themeColorUtilsClass + ) + if (moreDebug) { + val detailsSuffix = if (collectDetails && splashColor.details != null) { + " details=${splashColor.details}" + } else { + "" + } + YLog.debug( + "[$TAG] prepare flip splash color source=${splashColor.source} package=${activityInfo.packageName} " + + "theme=$theme color=${splashColor.color?.toArgbHex() ?: "null"} bounds=$bounds$detailsSuffix" + ) + } + } + + private fun applyTaskFillColor( + activityRecordImpl: Any?, + activityRecord: Any?, + config: CustomBoundsCompatAppConfig, + moreDebug: Boolean, + ) { + val task = activityRecord.call("getTask") + ?: activityRecord.call("getParent") + ?: return + val surfaceControl = task.field("mSurfaceControl") + ?: task.call("getSurfaceControl") + ?: return + val transaction = task.call("getSyncTransaction") + ?: activityRecord.call("getSyncTransaction") + ?: return + + if (!config.fillEnabled) { + unsetTaskSurfaceColor(transaction, surfaceControl) + requestTraversal(activityRecord) + if (moreDebug) { + YLog.debug("[$TAG] unset fill package=${config.packageName}") + } + return + } + + val fillColor = resolveFillColor( + activityRecordImpl = activityRecordImpl, + config = config, + ) ?: run { + if (moreDebug) { + YLog.debug( + "[$TAG] skip fill package=${config.packageName} reason=no_flip_color " + + describeFlipColorState(activityRecordImpl, activityRecord) + ) + } + return + } + setTaskSurfaceColor(transaction, surfaceControl, fillColor) + requestTraversal(activityRecord) + if (moreDebug) { + YLog.debug( + "[$TAG] set fill package=${config.packageName} mode=${config.fillMode} color=${fillColor.toArgbHex()}" + ) + } + } + + private fun resolveFillColor( + activityRecordImpl: Any?, + config: CustomBoundsCompatAppConfig, + ): Int? { + return when (config.fillMode) { + CustomBoundsFillMode.CUSTOM -> config.fillColorArgb.takeIf(::isUsableFillColor) + CustomBoundsFillMode.AUTO -> resolveBySystemFlipLogic(activityRecordImpl) + } + } + + private fun resolveBySystemFlipLogic(activityRecordImpl: Any?): Int? = runCatching { + activityRecordImpl ?: return@runCatching null + activityRecordImpl.field("mSplashBgColor")?.takeIf(::isUsableFillColor) + }.getOrNull() + + private fun computeSplashColorDirect( + activityRecordImpl: Any?, + activityRecord: Any?, + activityInfo: ActivityInfo, + theme: Int, + collectDetails: Boolean, + themeColorUtilsClass: Class<*>? + ): SplashColorResult { + val details = if (collectDetails) mutableListOf() else null + fun detailsText(): String? = details?.joinToString(";") + val systemContext = activityRecord.systemContext() + ?: activityRecordImpl.systemContext() + ?: return SplashColorResult(null, "none", detailsText()) + val packageContext = createPackageContextForActivity(systemContext, activityInfo) + ?: return SplashColorResult(null, "none", detailsText()) + val resolvedTheme = when { + theme != 0 -> theme + activityInfo.themeResource != 0 -> activityInfo.themeResource + else -> android.R.style.Theme_DeviceDefault_DayNight + } + packageContext.setTheme(resolvedTheme) + details?.add("resolvedTheme=$resolvedTheme") + + val miuiCandidate = resolveThemeColorByMiuiUtils( + context = packageContext, + packageName = activityInfo.packageName, + activityRecordImpl = activityRecordImpl, + themeColorUtilsClass = themeColorUtilsClass, + ) + if (miuiCandidate != null) { + details?.add("${miuiCandidate.source}=${miuiCandidate.color.toArgbHex()}") + if (isUsableFillColor(miuiCandidate.color)) { + return SplashColorResult(miuiCandidate.color, miuiCandidate.source, detailsText()) + } + } + + return SplashColorResult(null, "none", detailsText()) + } + + private data class SplashColorResult( + val color: Int?, + val source: String, + val details: String?, + ) + + private data class ColorCandidate( + val color: Int, + val source: String, + ) + + private fun resolveThemeColorByMiuiUtils( + context: Context, + packageName: String, + activityRecordImpl: Any?, + themeColorUtilsClass: Class<*>?, + ): ColorCandidate? = runCatching { + themeColorUtilsClass ?: return@runCatching null + val attrsClass = $$"com.android.server.wm.ThemeColorUtils$SplashScreenWindowAttrs".toClass( + themeColorUtilsClass.classLoader + ) + val attrs = attrsClass.getDeclaredConstructor() + .apply { isAccessible = true } + .newInstance() + themeColorUtilsClass.getDeclaredMethod("getWindowAttrs", Context::class.java, attrsClass) + .apply { isAccessible = true } + .invoke(null, context, attrs) + val color = themeColorUtilsClass.getDeclaredMethod( + "peekWindowBGColor", + Context::class.java, + attrsClass + ) + .apply { isAccessible = true } + .invoke(null, context, attrs) as? Int + ?: return@runCatching null + if (isUsableFillColor(color)) { + activityRecordImpl.setField("mSplashBgColor", color) + setThemeColorCache(themeColorUtilsClass, packageName, color) + } + ColorCandidate(color, "miuiThemeColorUtils") + }.getOrNull() + + private fun createPackageContextForActivity( + systemContext: Context, + activityInfo: ActivityInfo, + ): Context? { + return runCatching { + val userHandleClass = "android.os.UserHandle".toClass() + val userId = + userHandleClass.getDeclaredMethod("getUserId", Int::class.javaPrimitiveType) + .apply { isAccessible = true } + .invoke(null, activityInfo.applicationInfo.uid) as Int + val userHandle = userHandleClass.getDeclaredMethod("of", Int::class.javaPrimitiveType) + .apply { isAccessible = true } + .invoke(null, userId) + systemContext.javaClass.getMethod( + "createPackageContextAsUser", + String::class.java, + Int::class.javaPrimitiveType, + userHandleClass, + ).invoke( + systemContext, + activityInfo.packageName, + Context.CONTEXT_RESTRICTED, + userHandle + ) as? Context + }.getOrElse { + runCatching { + systemContext.createPackageContext( + activityInfo.packageName, + Context.CONTEXT_RESTRICTED + ) + }.getOrNull() + } + } + + private fun setThemeColorCache( + themeColorUtilsClass: Class<*>, + packageName: String, + color: Int, + ) { + runCatching { + @Suppress("UNCHECKED_CAST") + val cache = themeColorUtilsClass.getDeclaredField("mAppColorCache") + .apply { isAccessible = true } + .get(null) as? MutableMap + cache?.put(packageName, color) + } + } + + private fun describeFlipColorState(activityRecordImpl: Any?, activityRecord: Any?): String { + val splashBgColor = activityRecordImpl.field("mSplashBgColor") + val flipCutoutColor = activityRecordImpl.field("mFlipCutoutColor") + val taskDescription = activityRecord.field("taskDescription") + val navigationBarColor = taskDescription.call("getNavigationBarColor") + val systemContext = activityRecord.systemContext() + val nightMode = systemContext?.resources?.configuration?.isNightModeActive + return "splash=${splashBgColor?.toArgbHex() ?: "null"} " + + "flip=${flipCutoutColor?.toArgbHex() ?: "null"} " + + "taskDescription=${taskDescription != null} " + + "nav=${navigationBarColor?.toArgbHex() ?: "null"} " + + "night=$nightMode" + } + + private fun setTaskSurfaceColor(transaction: Any?, surfaceControl: Any?, colorInt: Int) { + val color = Color.valueOf(colorInt) + transaction.call( + "setColor", + surfaceControl, + floatArrayOf(color.red(), color.green(), color.blue()), + ) + } + + private fun unsetTaskSurfaceColor(transaction: Any?, surfaceControl: Any?) { + transaction.call("unsetColor", surfaceControl) + } + + private fun requestTraversal(activityRecord: Any?) { + activityRecord + .field("mWmService") + ?.field("mWindowPlacerLocked") + ?.call("requestTraversal") + } + + private fun isUsableFillColor(color: Int): Boolean = + color != 0 && (color ushr 24) != 0 + + private fun Int.toArgbHex(): String = + "#" + Integer.toHexString(this).padStart(8, '0').uppercase() + + private fun computeInsetsBounds(parentBounds: Rect, config: CustomBoundsCompatAppConfig): Rect { + val left = parentBounds.left + config.insetLeft + val top = parentBounds.top + config.insetTop + val right = (parentBounds.right - config.insetRight).coerceAtLeast(left + 1) + val bottom = (parentBounds.bottom - config.insetBottom).coerceAtLeast(top + 1) + return Rect(left, top, right, bottom) + } + + private fun computeCompatBounds( + parentBounds: Rect, + aspectRatio: Float, + gravity: Int, + scale: Float, + ): Rect { + val displaySize = Point(parentBounds.width(), parentBounds.height()) + val width = displaySize.x + val height = displaySize.y + val containerRatio = if (height > 0) width.toFloat() / height else 1f + val targetRatio = aspectRatio.takeIf { it > 0f } ?: 1f + + val unscaledWidth: Int + val unscaledHeight: Int + if (targetRatio >= containerRatio) { + unscaledWidth = width + unscaledHeight = (width / targetRatio).roundToInt().coerceAtLeast(1) + } else { + unscaledHeight = height + unscaledWidth = (height * targetRatio).roundToInt().coerceAtLeast(1) + } + + val scaledWidth = (unscaledWidth * scale).roundToInt().coerceIn(1, width) + val scaledHeight = (unscaledHeight * scale).roundToInt().coerceIn(1, height) + val left = when (gravity and HORIZONTAL_GRAVITY_MASK) { + LEFT -> 0 + RIGHT -> width - scaledWidth + else -> ((width - scaledWidth) / 2f).roundToInt() + } + val top = when (gravity and VERTICAL_GRAVITY_MASK) { + TOP -> 0 + BOTTOM -> height - scaledHeight + else -> ((height - scaledHeight) / 2f).roundToInt() + } + return Rect( + parentBounds.left + left, + parentBounds.top + top, + parentBounds.left + left + scaledWidth, + parentBounds.top + top + scaledHeight, + ) + } + + private fun applyResolvedConfiguration( + config: Configuration, + bounds: Rect, + densityDpi: Int, + rotation: Int, + ) { + config.densityDpi = densityDpi + val windowConfiguration = config.asResolver() + .firstField { name = "windowConfiguration" } + .get() ?: return + windowConfiguration.call("setBounds", bounds) + windowConfiguration.call("setAppBounds", bounds) + windowConfiguration.call("setMaxBounds", bounds) + if (rotation != CustomBoundsCompatConfigCodec.ROTATION_FOLLOW_SYSTEM) { + val surfaceRotation = rotation.toSurfaceRotation() + windowConfiguration.call("setRotation", surfaceRotation) + windowConfiguration.call("setDisplayRotation", surfaceRotation) + } + updateScreenDp(config, bounds) + } + + private fun updateScreenDp(config: Configuration, bounds: Rect) { + if (bounds.isEmpty || config.densityDpi <= 0) return + + val density = config.densityDpi / 160f + val widthDp = (bounds.width() / density).roundToInt() + val heightDp = (bounds.height() / density).roundToInt() + config.setHiddenIntField("compatScreenWidthDp", widthDp) + config.screenWidthDp = widthDp + config.setHiddenIntField("compatScreenHeightDp", heightDp) + config.screenHeightDp = heightDp + config.orientation = if (bounds.width() <= bounds.height()) { + Configuration.ORIENTATION_PORTRAIT + } else { + Configuration.ORIENTATION_LANDSCAPE + } + val shortSizeDp = min(widthDp, heightDp) + val longSizeDp = max(widthDp, heightDp) + config.setHiddenIntField("compatSmallestScreenWidthDp", shortSizeDp) + config.smallestScreenWidthDp = shortSizeDp + reduceScreenLayout(config.screenLayout, longSizeDp, shortSizeDp)?.let { + config.screenLayout = it + } + } + + private fun Configuration.setHiddenIntField(name: String, value: Int) { + runCatching { + asResolver().firstField { this.name = name }.set(value) + } + } + + private fun reduceScreenLayout(screenLayout: Int, longSizeDp: Int, shortSizeDp: Int): Int? { + return runCatching { + val resolver = Configuration::class.java.resolve() + val reset = resolver.firstMethod { + name = "resetScreenLayout" + parameterCount = 1 + }.invoke(screenLayout) + resolver.firstMethod { + name = "reduceScreenLayout" + parameterCount = 3 + }.invoke(reset, longSizeDp, shortSizeDp) + }.getOrNull() + } + + private fun getWindowConfigurationBounds(config: Configuration): Rect? = runCatching { + config.asResolver() + .firstField { name = "windowConfiguration" } + .get() + ?.call("getBounds") + }.getOrNull() + + private fun Int.toSurfaceRotation(): Int = when (this) { + 90 -> 1 + 180 -> 2 + 270 -> 3 + else -> 0 + } + + private fun Any?.field(name: String): T? = runCatching { + this?.asResolver()?.firstField { this.name = name }?.get() + }.getOrNull() + + private fun Any?.setField(name: String, value: Any?) { + runCatching { + this?.asResolver()?.firstField { this.name = name }?.set(value) + } + } + + private fun Any?.systemContext(): Context? = + field("mAtmService")?.field("mContext") + ?: field("mWmService")?.field("mContext") + + private fun Any?.call(name: String, vararg args: Any?): T? = runCatching { + this?.asResolver()?.firstMethod { + this.name = name + parameterCount = args.size + }?.invoke(*args) + }.getOrNull() + + private object CustomBoundsCompatHookConfig { + @Volatile + private var lastRaw: String? = null + + @Volatile + private var lastConfigs: Map = emptyMap() + + fun find(raw: String, packageName: String?): CustomBoundsCompatAppConfig? { + if (packageName.isNullOrBlank()) return null + if (raw != lastRaw) { + lastConfigs = CustomBoundsCompatConfigCodec.parse(raw) + .associateBy { it.packageName } + lastRaw = raw + } + return lastConfigs[packageName] + } + } + + private companion object { + const val TAG = "CustomRearBounds" + const val TARGET_DISPLAY_ID = 1 + const val HORIZONTAL_GRAVITY_MASK = 7 + const val VERTICAL_GRAVITY_MASK = 112 + const val LEFT = 3 + const val RIGHT = 5 + const val TOP = 48 + const val BOTTOM = 80 + + @Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants") + const val SHOULD_LOG_DETAILS = BuildConfig.BUILD_CHANNEL == "dev" + } +} diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/DisableSubScreenDoubleTapSleepHook.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/DisableSubScreenDoubleTapSleepHook.kt new file mode 100644 index 0000000..edf6c2a --- /dev/null +++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/DisableSubScreenDoubleTapSleepHook.kt @@ -0,0 +1,64 @@ +package hk.uwu.reareye.hook.scopes.system.modules + +import android.view.MotionEvent +import com.highcapable.kavaref.KavaRef.Companion.asResolver +import com.highcapable.kavaref.KavaRef.Companion.resolve +import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker +import com.highcapable.yukihookapi.hook.log.YLog +import hk.uwu.reareye.ui.config.ConfigKeys + +class DisableSubScreenDoubleTapSleepHook : YukiBaseHooker() { + @Volatile + private var focusedPackageName: String? = null + + override fun onHook() { + loadSystem { + val clz = + "com.miui.server.input.gesture.multifingergesture.gesture.MiuiSubscreenDoubleTapGesture" + .toClass() + .resolve() + val managerRef = + "com.miui.server.input.gesture.multifingergesture.MiuiSubScreenMultiFingerGestureManager" + .toClass() + .resolve() + + managerRef.firstMethod { + name = "onFocusedWindowChanged" + parameterCount = 3 + }.hook().after { + focusedPackageName = args(2).any().owningPackage() + } + + clz.firstMethod { + name = "onPointerEvent" + returnType = Void.TYPE + parameters(MotionEvent::class.java) + }.hook().replaceUnit { + val whitelist = prefs.getStringSet( + ConfigKeys.SUBSCREEN_DOUBLE_TAP_SLEEP_DISABLED_APPS, + ) + val packageName = focusedPackageName ?: managerRef.firstMethod { + name = "getFocusedWindow" + }.invoke().owningPackage()?.also { + focusedPackageName = it + } + if (packageName != null && packageName in whitelist) { + if (prefs.getBoolean(ConfigKeys.MORE_DEBUG, false)) { + YLog.debug("Rejected subscreen double tap sleep gesture package=$packageName") + } + return@replaceUnit + } + invokeOriginal(*args) + } + } + } + + private fun Any?.owningPackage(): String? { + return runCatching { + this?.asResolver()?.firstMethod { + name = "getOwningPackage" + returnType = String::class.java + }?.invoke() as? String + }.getOrNull() + } +} diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/DisableSubScreenDoubleTapWakeHook.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/DisableSubScreenDoubleTapWakeHook.kt new file mode 100644 index 0000000..f3bfc34 --- /dev/null +++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/DisableSubScreenDoubleTapWakeHook.kt @@ -0,0 +1,40 @@ +package hk.uwu.reareye.hook.scopes.system.modules + +import com.highcapable.kavaref.KavaRef.Companion.resolve +import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker +import com.highcapable.yukihookapi.hook.log.YLog +import hk.uwu.reareye.ui.config.ConfigKeys + +class DisableSubScreenDoubleTapWakeHook : YukiBaseHooker() { + override fun onHook() { + loadSystem { + val dualScreenCoverManagerRef = "com.android.server.power.DualScreenCoverManager" + .toClass() + .resolve() + + dualScreenCoverManagerRef.firstMethod { + name = "isScreenSkippedWakeup" + parameters(Int::class.java, String::class.java, Int::class.java) + returnType = Boolean::class.java + }.hook().before { + val groupId = args(0).int() + val details = args(1).string() + val packageName = instance.mainDisplayForegroundPackageName() + if (groupId == 1 && details == WAKE_REASON_DOUBLE_TAP && + packageName in prefs.getStringSet( + ConfigKeys.SUBSCREEN_DOUBLE_TAP_WAKE_DISABLED_APPS, + ) + ) { + result = true + if (prefs.getBoolean(ConfigKeys.MORE_DEBUG, false)) { + YLog.debug("Skip subscreen double tap wake package=$packageName") + } + } + } + } + } + + companion object { + private const val WAKE_REASON_DOUBLE_TAP = "android.policy:KEY" + } +} diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/DisableSubScreenHighLoadModeHook.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/DisableSubScreenHighLoadModeHook.kt new file mode 100644 index 0000000..60d23fa --- /dev/null +++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/DisableSubScreenHighLoadModeHook.kt @@ -0,0 +1,41 @@ +package hk.uwu.reareye.hook.scopes.system.modules + +import com.highcapable.kavaref.KavaRef.Companion.resolve +import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker +import com.highcapable.yukihookapi.hook.log.YLog +import hk.uwu.reareye.ui.config.ConfigKeys + +class DisableSubScreenHighLoadModeHook : YukiBaseHooker() { + override fun onHook() { + loadSystem { + val dualScreenCoverManagerRef = "com.android.server.power.DualScreenCoverManager" + .toClass() + .resolve() + + dualScreenCoverManagerRef.firstMethod { + name = "updateHighLoadSceneMode" + parameters(Int::class.java, Boolean::class.java) + returnType = Void.TYPE + }.hook().replaceUnit { + val value = args(1).boolean() + if (!value) { + invokeOriginal(*args) + return@replaceUnit + } + + val packageName = instance.mainDisplayForegroundPackageName() + if (packageName in prefs.getStringSet( + ConfigKeys.SUBSCREEN_HIGH_LOAD_MODE_DISABLED_APPS, + ) + ) { + result = null + if (prefs.getBoolean(ConfigKeys.MORE_DEBUG, false)) { + YLog.debug("Skip subscreen high load mode package=$packageName") + } + return@replaceUnit + } + invokeOriginal(*args) + } + } + } +} diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/SystemForegroundAppResolver.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/SystemForegroundAppResolver.kt new file mode 100644 index 0000000..8f65b4a --- /dev/null +++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/SystemForegroundAppResolver.kt @@ -0,0 +1,15 @@ +package hk.uwu.reareye.hook.scopes.system.modules + +import com.highcapable.kavaref.KavaRef.Companion.asResolver + +internal fun Any?.mainDisplayForegroundPackageName(): String? = runCatching { + val instance = this ?: return null + val powerManagerServiceImpl = instance.asResolver().firstField { + name = "mPowerManagerServiceImpl" + }.get() ?: return null + + powerManagerServiceImpl.asResolver().firstField { + name = "mForegroundAppPackageName" + type = String::class.java + }.get() +}.getOrNull() diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/ThemeManagerScope.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/ThemeManagerScope.kt index 37b9eab..2541af6 100644 --- a/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/ThemeManagerScope.kt +++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/ThemeManagerScope.kt @@ -1,6 +1,7 @@ package hk.uwu.reareye.hook.scopes.thememanager import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker +import com.highcapable.yukihookapi.hook.log.YLog import hk.uwu.reareye.hook.scopes.Scope import hk.uwu.reareye.hook.scopes.thememanager.modules.RearWallpaperThemeManagerSyncHook import hk.uwu.reareye.hook.scopes.thememanager.modules.UnlockTemplateMaximumLimitHook @@ -8,10 +9,18 @@ import hk.uwu.reareye.hook.scopes.thememanager.modules.UnlockVideoRestrictionsHo import hk.uwu.reareye.hook.scopes.thememanager.modules.UnmuteVideoWallpaperHook class ThemeManagerScope : Scope { - override val hooks: List = listOf( - UnlockVideoRestrictionsHook(), - UnlockTemplateMaximumLimitHook(), - UnmuteVideoWallpaperHook(), - RearWallpaperThemeManagerSyncHook() - ) + override val hooks: List = buildList { + if (isRearDevice) { + addAll( + listOf( + UnlockVideoRestrictionsHook(), + UnlockTemplateMaximumLimitHook(), + UnmuteVideoWallpaperHook(), + RearWallpaperThemeManagerSyncHook() + ) + ) + } else { + YLog.debug("This device is not support rear screen, skip load some features that this device is not supported") + } + } } diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/modules/UnlockVideoRestrictionsHook.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/modules/UnlockVideoRestrictionsHook.kt index 2e93925..858439a 100644 --- a/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/modules/UnlockVideoRestrictionsHook.kt +++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/modules/UnlockVideoRestrictionsHook.kt @@ -113,496 +113,496 @@ class UnlockVideoRestrictionsHook : YukiBaseHooker() { val videoEditPoint = resolveVideoEditPlayCreatedMethod(bridge) val fpsLimitPoint = resolveVideoEditFpsLimitMethod(bridge) - val editorConfigBuildPoint = - resolveVideoEditorConfigBuildMethod(bridge) + val editorConfigBuildPoint = + resolveVideoEditorConfigBuildMethod(bridge) val checkDepthPoint = resolveVideoDepthCheckMethod(bridge) - val timelineGetInstancePoint = - resolveVideoTimelineGetInstanceMethod(bridge) - val timelineAttachTexturePoint = - resolveVideoTimelineAttachTextureMethod(bridge) - val timelineGetDurationPoint = - resolveVideoTimelineGetDurationMethod(bridge) - val timelinePreparePoint = - resolveVideoTimelinePrepareMethod(bridge) - val timelineExportPoint = - resolveVideoTimelineExportMethod(bridge) + val timelineGetInstancePoint = + resolveVideoTimelineGetInstanceMethod(bridge) + val timelineAttachTexturePoint = + resolveVideoTimelineAttachTextureMethod(bridge) + val timelineGetDurationPoint = + resolveVideoTimelineGetDurationMethod(bridge) + val timelinePreparePoint = + resolveVideoTimelinePrepareMethod(bridge) + val timelineExportPoint = + resolveVideoTimelineExportMethod(bridge) val toastTextPoint = resolveVideoToastTextMethod(bridge) - val operationCurrentTimePoint = - resolveVideoOperationCurrentTimeMethod(bridge) + val operationCurrentTimePoint = + resolveVideoOperationCurrentTimeMethod(bridge) val clipFrameLoadPoint = resolveVideoClipFrameLoadMethod(bridge) val coderHashPoint = resolveVideoHashStringMethod(bridge) - val exportConfigSetFpsPoint = - resolveVideoExportConfigSetFpsMethod(bridge) + val exportConfigSetFpsPoint = + resolveVideoExportConfigSetFpsMethod(bridge) val gsonSerializePoint = resolveVideoGsonSerializeMethod(bridge) - val videoEditClz = videoEditPoint.className.toClass() - val videoEditRef = videoEditClz.resolve() - val fpsLimitClz = fpsLimitPoint.className.toClass().resolve() - val editorCfgBuilderClz = editorConfigBuildPoint.className.toClass().resolve() - val checkDepthClz = checkDepthPoint.className.toClass().resolve() - val timelineClz = timelineGetInstancePoint.className.toClass() - val timelineRef = timelineClz.resolve() - val toastUtilsRef = toastTextPoint.className.toClass().resolve() - val coderUtilsRef = coderHashPoint.className.toClass().resolve() - val gsonUtilsClz = gsonSerializePoint.className.toClass().resolve() + val videoEditClz = videoEditPoint.className.toClass() + val videoEditRef = videoEditClz.resolve() + val fpsLimitClz = fpsLimitPoint.className.toClass().resolve() + val editorCfgBuilderClz = editorConfigBuildPoint.className.toClass().resolve() + val checkDepthClz = checkDepthPoint.className.toClass().resolve() + val timelineClz = timelineGetInstancePoint.className.toClass() + val timelineRef = timelineClz.resolve() + val toastUtilsRef = toastTextPoint.className.toClass().resolve() + val coderUtilsRef = coderHashPoint.className.toClass().resolve() + val gsonUtilsClz = gsonSerializePoint.className.toClass().resolve() val frameLoaderClassName = resolveDexKitClassValue( - bridge = bridge, - cacheKey = VIDEO_FRAME_LOADER_CLASS_CACHE_KEY, + bridge = bridge, + cacheKey = VIDEO_FRAME_LOADER_CLASS_CACHE_KEY, selector = { it.className.substringBefore('$') }, - ) { - findClass { - matcher { - usingStrings( - "MiVideoFrameLoader", - "loadFrameTime width=%d height=%d key=%s,timeMicros=%d,cost=%d", - ) - } - }.singleOrNull() - } ?: error("DexKit failed to resolve video frame loader class") + ) { + findClass { + matcher { + usingStrings( + "MiVideoFrameLoader", + "loadFrameTime width=%d height=%d key=%s,timeMicros=%d,cost=%d", + ) + } + }.singleOrNull() + } ?: error("DexKit failed to resolve video frame loader class") val exportConfigClassName = resolveDexKitClassValue( - bridge = bridge, - cacheKey = VIDEO_EXPORT_CONFIG_CLASS_CACHE_KEY, - ) { - findClass { - searchPackages("com.android.thememanager.videoedit.entity") - matcher { - fields { - addForType(Int::class.java) - addForType(Size::class.java) - addForType(Boolean::class.java) - addForType(String::class.java) - } - } - }.singleOrNull() - } ?: error("DexKit failed to resolve export config class") - val frameLoaderClz = frameLoaderClassName.toClass().resolve() - val exportConfigClz = exportConfigClassName.toClass().resolve() - - fun resolveFieldName( - cacheKey: String, - fallbackField: String, - finder: DexKitBridge.() -> org.luckypray.dexkit.result.FieldData?, - ): String { - return resolveDexKitFieldValue( - bridge = bridge, - cacheKey = cacheKey, - ) { - finder() - } ?: fallbackField + bridge = bridge, + cacheKey = VIDEO_EXPORT_CONFIG_CLASS_CACHE_KEY, + ) { + findClass { + searchPackages("com.android.thememanager.videoedit.entity") + matcher { + fields { + addForType(Int::class.java) + addForType(Size::class.java) + addForType(Boolean::class.java) + addForType(String::class.java) + } } + }.singleOrNull() + } ?: error("DexKit failed to resolve export config class") + val frameLoaderClz = frameLoaderClassName.toClass().resolve() + val exportConfigClz = exportConfigClassName.toClass().resolve() - val playViewFieldName = resolveFieldName( - VIDEO_EDIT_PLAY_VIEW_FIELD_CACHE_KEY, - "q", - ) { - findField { - searchPackages("com.android.thememanager.videoedit") - matcher { - declaredClass = videoEditPoint.className - type = "com.android.thememanager.videoedit.widget.VlogPlayView" - } - }.singleOrNull() + fun resolveFieldName( + cacheKey: String, + fallbackField: String, + finder: DexKitBridge.() -> org.luckypray.dexkit.result.FieldData?, + ): String { + return resolveDexKitFieldValue( + bridge = bridge, + cacheKey = cacheKey, + ) { + finder() + } ?: fallbackField + } + + val playViewFieldName = resolveFieldName( + VIDEO_EDIT_PLAY_VIEW_FIELD_CACHE_KEY, + "q", + ) { + findField { + searchPackages("com.android.thememanager.videoedit") + matcher { + declaredClass = videoEditPoint.className + type = "com.android.thememanager.videoedit.widget.VlogPlayView" } - val configFieldName = resolveFieldName( - VIDEO_EDIT_CONFIG_FIELD_CACHE_KEY, - "s", - ) { - findField { - searchPackages("com.android.thememanager.videoedit") - matcher { - declaredClass = videoEditPoint.className - type = "com.android.thememanager.videoedit.VideoEditorConfig" - } - }.singleOrNull() + }.singleOrNull() + } + val configFieldName = resolveFieldName( + VIDEO_EDIT_CONFIG_FIELD_CACHE_KEY, + "s", + ) { + findField { + searchPackages("com.android.thememanager.videoedit") + matcher { + declaredClass = videoEditPoint.className + type = "com.android.thememanager.videoedit.VideoEditorConfig" } - val operationViewFieldName = resolveFieldName( - VIDEO_EDIT_OPERATION_VIEW_FIELD_CACHE_KEY, - "n", - ) { - findField { - searchPackages("com.android.thememanager.videoedit") - matcher { - declaredClass = videoEditPoint.className - type = - "com.android.thememanager.videoedit.widget.SingleEditOperationView" - } - }.singleOrNull() + }.singleOrNull() + } + val operationViewFieldName = resolveFieldName( + VIDEO_EDIT_OPERATION_VIEW_FIELD_CACHE_KEY, + "n", + ) { + findField { + searchPackages("com.android.thememanager.videoedit") + matcher { + declaredClass = videoEditPoint.className + type = + "com.android.thememanager.videoedit.widget.SingleEditOperationView" } - val trimInFieldName = resolveFieldName( - VIDEO_EDIT_TRIM_IN_FIELD_CACHE_KEY, - "i", - ) { - findField { - searchPackages("com.android.thememanager.videoedit") - matcher { + }.singleOrNull() + } + val trimInFieldName = resolveFieldName( + VIDEO_EDIT_TRIM_IN_FIELD_CACHE_KEY, + "i", + ) { + findField { + searchPackages("com.android.thememanager.videoedit") + matcher { + declaredClass = videoEditPoint.className + type = "long" + readMethods { + add { declaredClass = videoEditPoint.className - type = "long" - readMethods { - add { - declaredClass = videoEditPoint.className - name = videoEditPoint.methodName - paramCount(0) - returnType = "void" - usingStrings("onPlayViewCreated") - } - add { - declaredClass = fpsLimitPoint.className - name = fpsLimitPoint.methodName - paramCount(0) - returnType = "void" - usingStrings("ExportConfig %s", "export videopath is ") - } - } + name = videoEditPoint.methodName + paramCount(0) + returnType = "void" + usingStrings("onPlayViewCreated") } - }.singleOrNull() - } - val trimOutFieldName = resolveFieldName( - VIDEO_EDIT_TRIM_OUT_FIELD_CACHE_KEY, - "z", - ) { - findField { - searchPackages("com.android.thememanager.videoedit") - matcher { - declaredClass = videoEditPoint.className - type = "long" - readMethods { - add { - declaredClass = fpsLimitPoint.className - name = fpsLimitPoint.methodName - paramCount(0) - returnType = "void" - usingStrings("ExportConfig %s", "export videopath is ") - } - } - writeMethods { - add { - declaredClass = videoEditPoint.className - name = videoEditPoint.methodName - paramCount(0) - returnType = "void" - usingStrings("onPlayViewCreated") - } - } + add { + declaredClass = fpsLimitPoint.className + name = fpsLimitPoint.methodName + paramCount(0) + returnType = "void" + usingStrings("ExportConfig %s", "export videopath is ") } - }.singleOrNull() + } } - val frameLoaderFieldName = resolveFieldName( - VIDEO_EDIT_FRAME_LOADER_FIELD_CACHE_KEY, - "p", - ) { - findField { - searchPackages("com.android.thememanager.videoedit") - matcher { - declaredClass = videoEditPoint.className - type = "com.android.thememanager.videoedit.y" + }.singleOrNull() + } + val trimOutFieldName = resolveFieldName( + VIDEO_EDIT_TRIM_OUT_FIELD_CACHE_KEY, + "z", + ) { + findField { + searchPackages("com.android.thememanager.videoedit") + matcher { + declaredClass = videoEditPoint.className + type = "long" + readMethods { + add { + declaredClass = fpsLimitPoint.className + name = fpsLimitPoint.methodName + paramCount(0) + returnType = "void" + usingStrings("ExportConfig %s", "export videopath is ") } - }.singleOrNull() - } - val clipFrameFieldName = resolveFieldName( - VIDEO_EDIT_CLIP_FRAME_FIELD_CACHE_KEY, - "g", - ) { - findField { - searchPackages("com.android.thememanager.videoedit") - matcher { + } + writeMethods { + add { declaredClass = videoEditPoint.className - type = "com.android.thememanager.videoedit.widget.ClipFrameView" + name = videoEditPoint.methodName + paramCount(0) + returnType = "void" + usingStrings("onPlayViewCreated") } - }.singleOrNull() + } } - val clipListenerFieldName = resolveFieldName( - VIDEO_EDIT_CLIP_LISTENER_FIELD_CACHE_KEY, - "j", - ) { - findField { - searchPackages("com.android.thememanager.videoedit") - matcher { - declaredClass = videoEditPoint.className - type = - $$"com.android.thememanager.videoedit.widget.ClipFrameView$zy" - } - }.singleOrNull() + }.singleOrNull() + } + val frameLoaderFieldName = resolveFieldName( + VIDEO_EDIT_FRAME_LOADER_FIELD_CACHE_KEY, + "p", + ) { + findField { + searchPackages("com.android.thememanager.videoedit") + matcher { + declaredClass = videoEditPoint.className + type = "com.android.thememanager.videoedit.y" + } + }.singleOrNull() + } + val clipFrameFieldName = resolveFieldName( + VIDEO_EDIT_CLIP_FRAME_FIELD_CACHE_KEY, + "g", + ) { + findField { + searchPackages("com.android.thememanager.videoedit") + matcher { + declaredClass = videoEditPoint.className + type = "com.android.thememanager.videoedit.widget.ClipFrameView" } - val videoUriFieldName = resolveFieldName( - VIDEO_EDIT_VIDEO_URI_FIELD_CACHE_KEY, - "y", - ) { - findField { - searchPackages("com.android.thememanager.videoedit") - matcher { + }.singleOrNull() + } + val clipListenerFieldName = resolveFieldName( + VIDEO_EDIT_CLIP_LISTENER_FIELD_CACHE_KEY, + "j", + ) { + findField { + searchPackages("com.android.thememanager.videoedit") + matcher { + declaredClass = videoEditPoint.className + type = + $$"com.android.thememanager.videoedit.widget.ClipFrameView$zy" + } + }.singleOrNull() + } + val videoUriFieldName = resolveFieldName( + VIDEO_EDIT_VIDEO_URI_FIELD_CACHE_KEY, + "y", + ) { + findField { + searchPackages("com.android.thememanager.videoedit") + matcher { + declaredClass = videoEditPoint.className + type = "java.lang.String" + readMethods { + add { declaredClass = videoEditPoint.className - type = "java.lang.String" - readMethods { - add { - declaredClass = videoEditPoint.className - name = videoEditPoint.methodName - paramCount(0) - returnType = "void" - usingStrings("onPlayViewCreated") - } - add { - declaredClass = fpsLimitPoint.className - name = fpsLimitPoint.methodName - paramCount(0) - returnType = "void" - usingStrings("ExportConfig %s", "export videopath is ") - } - } + name = videoEditPoint.methodName + paramCount(0) + returnType = "void" + usingStrings("onPlayViewCreated") + } + add { + declaredClass = fpsLimitPoint.className + name = fpsLimitPoint.methodName + paramCount(0) + returnType = "void" + usingStrings("ExportConfig %s", "export videopath is ") } - }.singleOrNull() + } } - val exportPathFieldName = resolveFieldName( - VIDEO_EDIT_EXPORT_PATH_FIELD_CACHE_KEY, - "c", - ) { - findField { - searchPackages("com.android.thememanager.videoedit") - matcher { - declaredClass = videoEditPoint.className - type = "java.lang.String" - writeMethods { - add { - declaredClass = fpsLimitPoint.className - name = fpsLimitPoint.methodName - paramCount(0) - returnType = "void" - usingStrings("ExportConfig %s", "export videopath is ") - } - } + }.singleOrNull() + } + val exportPathFieldName = resolveFieldName( + VIDEO_EDIT_EXPORT_PATH_FIELD_CACHE_KEY, + "c", + ) { + findField { + searchPackages("com.android.thememanager.videoedit") + matcher { + declaredClass = videoEditPoint.className + type = "java.lang.String" + writeMethods { + add { + declaredClass = fpsLimitPoint.className + name = fpsLimitPoint.methodName + paramCount(0) + returnType = "void" + usingStrings("ExportConfig %s", "export videopath is ") } - }.singleOrNull() + } } + }.singleOrNull() + } val durationCropMatchResult = resolveDexKitClassValue( - bridge = bridge, - cacheKey = durationCropCacheKey, - ) { - findClass { - searchPackages("com.android.thememanager.util") - matcher { - modifiers = Modifier.PUBLIC or Modifier.FINAL - fieldCount(1) - methods { - add { - name = "toString" - returnType(String::class.java) - usingStrings("DurationCrop") - } - } + bridge = bridge, + cacheKey = durationCropCacheKey, + ) { + findClass { + searchPackages("com.android.thememanager.util") + matcher { + modifiers = Modifier.PUBLIC or Modifier.FINAL + fieldCount(1) + methods { + add { + name = "toString" + returnType(String::class.java) + usingStrings("DurationCrop") } - }.singleOrNull() + } } - val durationCropClz = (durationCropMatchResult - ?: $$"com.android.thememanager.util.uc$k$toq").toClass() + }.singleOrNull() + } + val durationCropClz = (durationCropMatchResult + ?: $$"com.android.thememanager.util.uc$k$toq").toClass() val historyHelperResult = resolveDexKitClassValue( - bridge = bridge, - cacheKey = historyHelperCacheKey, - ) { - findClass { - searchPackages("com.android.thememanager.settings") - matcher { - modifiers = Modifier.PUBLIC - fields { - addForType(String::class.java) - addForType(Any::class.java) - count = 2 - } - usingStrings("updateVideoResource") - } - }.singleOrNull() - } - val historyHelperClz = - (historyHelperResult ?: "com.android.thememanager.settings.a9").toClass() - - checkDepthClz.firstMethod { - name = checkDepthPoint.methodName - }.hook().after { - if (!prefs.getBoolean( - ConfigKeys.HOOK_UNLOCK_VIDEO_RESTRICTIONS, - true - ) - ) return@after - val ref = instance.asResolver() - val videoCfg = ref.firstField { - name = $$"$videoConfig" - }.get() ?: return@after - if (videoCfg.asResolver().field { - type = Boolean::class.java - }.all { it.get() == true } && !state.load()) { - result = durationCropClz.resolve().firstField { - type = durationCropClz - }.get() - } else { - state.store(false) + bridge = bridge, + cacheKey = historyHelperCacheKey, + ) { + findClass { + searchPackages("com.android.thememanager.settings") + matcher { + modifiers = Modifier.PUBLIC + fields { + addForType(String::class.java) + addForType(Any::class.java) + count = 2 } + usingStrings("updateVideoResource") } + }.singleOrNull() + } + val historyHelperClz = + (historyHelperResult ?: "com.android.thememanager.settings.a9").toClass() - // 修补视频编辑器 - videoEditRef.firstMethod { - name = videoEditPoint.methodName - returnType = Void.TYPE - }.hook().replaceUnit { - if (!prefs.getBoolean(ConfigKeys.HOOK_UNLOCK_VIDEO_RESTRICTIONS, true)) { - invokeOriginal() - return@replaceUnit - } - val iRef = instance.asResolver() - val playViewRef = iRef.firstField { - name = playViewFieldName - }.get()!!.asResolver() - val videoConfig = iRef.firstField { - name = configFieldName - }.get() - val currentTrimIn = iRef.firstField { - name = trimInFieldName - }.get() as? Long ?: 0L - val operationViewRef = iRef.firstField { - name = operationViewFieldName - }.get()!!.asResolver() - val clipFrameRef = iRef.firstField { - name = clipFrameFieldName - }.get()!!.asResolver() - val videoUri = iRef.firstField { - name = videoUriFieldName - }.get() - val sInstance = timelineRef.firstMethod { - name = timelineGetInstancePoint.methodName - returnType = timelineClz - }.invoke()!! - sInstance.asResolver().firstMethod { - name = timelineAttachTexturePoint.methodName - returnType = Void.TYPE - }.invoke(playViewRef.firstMethod { - name = "getTextureView" - }.invoke(), videoConfig) - val duration: Long = - sInstance.asResolver().firstMethod { - name = timelineGetDurationPoint.methodName - }.invoke() as Long - val activity = instance() - if (duration <= 0) { - toastUtilsRef.firstMethod { name = toastTextPoint.methodName } - .invoke(activity.resources.getString(2131888794)) - Log.e("VideoEditActivity", "onPlayViewCreated: originDuration = 0") - activity.finish() - return@replaceUnit - } - iRef.firstField { name = trimOutFieldName }.set(duration) - operationViewRef.firstMethod { name = operationCurrentTimePoint.methodName } - .invoke(currentTrimIn) - operationViewRef.firstMethod { name = "setTotalTime" }.invoke(duration) - val yVar = frameLoaderClz.firstConstructor { - parameterCount = 0 - }.create() - iRef.firstField { name = frameLoaderFieldName }.set(yVar) - clipFrameRef.firstMethod { name = "setVideoFrameLoader" }.invoke(yVar) - clipFrameRef.firstMethod { name = "setClipFrameListener" } - .invoke(iRef.firstField { name = clipListenerFieldName }.get()) - clipFrameRef.firstMethod { name = clipFrameLoadPoint.methodName }.invoke( - videoUri, - duration, - duration - ) - sInstance.asResolver().firstMethod { - name = timelinePreparePoint.methodName - parameters(Int::class.java) - }.invoke(currentTrimIn.toInt()) - state.store(true) - } + checkDepthClz.firstMethod { + name = checkDepthPoint.methodName + }.hook().after { + if (!prefs.getBoolean( + ConfigKeys.HOOK_UNLOCK_VIDEO_RESTRICTIONS, + true + ) + ) return@after + val ref = instance.asResolver() + val videoCfg = ref.firstField { + name = $$"$videoConfig" + }.get() ?: return@after + if (videoCfg.asResolver().field { + type = Boolean::class.java + }.all { it.get() == true } && !state.load()) { + result = durationCropClz.resolve().firstField { + type = durationCropClz + }.get() + } else { + state.store(false) + } + } - // 修补帧率限制 - fpsLimitClz.firstMethod { - name = fpsLimitPoint.methodName - }.hook().replaceUnit { - if (!prefs.getBoolean(ConfigKeys.HOOK_UNLOCK_VIDEO_RESTRICTIONS, true)) { - invokeOriginal() - return@replaceUnit - } - val strF7l8 = - historyHelperClz.resolve().firstMethod { - returnType = String::class.java - parameterCount = 0 - }.invoke() as String - val iVEA = instance.asResolver().firstField { type = videoEditClz }.get()!! - val iRef = iVEA.asResolver() - val yObj = iRef.firstField { name = videoUriFieldName }.get() - val cFieldRef = iRef.firstField { name = exportPathFieldName } - cFieldRef.set( - strF7l8 + (coderUtilsRef.firstMethod { - name = coderHashPoint.methodName - }.invoke(yObj) as String) + ".mp4" - ) - val frameRetriever = - "com.xiaomi.milab.videosdk.FrameRetriever".toClass().resolve() - .firstConstructor().create().asResolver() - frameRetriever.firstMethod { name = "setDataSource" }.invoke(yObj) - val width = frameRetriever.firstMethod { name = "getWidth" }.invoke() as Int - val height = - frameRetriever.firstMethod { name = "getHeight" }.invoke() as Int - val fps = frameRetriever.firstMethod { name = "getFPS" }.invoke() as Float - val bitrate = - frameRetriever.firstMethod { name = "getBitrate" }.invoke() as Long - frameRetriever.firstMethod { name = "release" }.invoke() - if (width <= 0 || height <= 0) { - iRef.firstMethod { name = "onExportFail" }.invoke() - return@replaceUnit - } - val (outWidth, outHeight) = computeExportOutputSize(width, height, 1080) - val toqVar = exportConfigClz.firstConstructor { - parameterCount = 5 - }.create( - true, - cFieldRef.get(), - Size(outWidth, outHeight), - (((((bitrate / (width * height)) * outWidth) * outHeight) / fps) * fps).toInt(), - 0 - ) - toqVar.asResolver().firstMethod { - name = exportConfigSetFpsPoint.methodName - }.invoke(fps.toInt()) - Log.d( - "VideoEditActivity", - String.format( - "ExportConfig %s", - gsonUtilsClz.firstMethod { name = gsonSerializePoint.methodName } - .invoke(toqVar) - ) - ) - Log.d("lollipop", "export videopath is " + cFieldRef.get()) - val qRef = timelineRef.firstMethod { - name = timelineGetInstancePoint.methodName - }.invoke()!!.asResolver() - qRef.firstMethod { - name = timelineExportPoint.methodName - }.invoke( - iRef.firstField { name = trimInFieldName }.get(), - iRef.firstField { name = trimOutFieldName }.get(), - toqVar - ) - } + // 修补视频编辑器 + videoEditRef.firstMethod { + name = videoEditPoint.methodName + returnType = Void.TYPE + }.hook().replaceUnit { + if (!prefs.getBoolean(ConfigKeys.HOOK_UNLOCK_VIDEO_RESTRICTIONS, true)) { + invokeOriginal() + return@replaceUnit + } + val iRef = instance.asResolver() + val playViewRef = iRef.firstField { + name = playViewFieldName + }.get()!!.asResolver() + val videoConfig = iRef.firstField { + name = configFieldName + }.get() + val currentTrimIn = iRef.firstField { + name = trimInFieldName + }.get() as? Long ?: 0L + val operationViewRef = iRef.firstField { + name = operationViewFieldName + }.get()!!.asResolver() + val clipFrameRef = iRef.firstField { + name = clipFrameFieldName + }.get()!!.asResolver() + val videoUri = iRef.firstField { + name = videoUriFieldName + }.get() + val sInstance = timelineRef.firstMethod { + name = timelineGetInstancePoint.methodName + returnType = timelineClz + }.invoke()!! + sInstance.asResolver().firstMethod { + name = timelineAttachTexturePoint.methodName + returnType = Void.TYPE + }.invoke(playViewRef.firstMethod { + name = "getTextureView" + }.invoke(), videoConfig) + val duration: Long = + sInstance.asResolver().firstMethod { + name = timelineGetDurationPoint.methodName + }.invoke() as Long + val activity = instance() + if (duration <= 0) { + toastUtilsRef.firstMethod { name = toastTextPoint.methodName } + .invoke(activity.resources.getString(2131888794)) + Log.e("VideoEditActivity", "onPlayViewCreated: originDuration = 0") + activity.finish() + return@replaceUnit + } + iRef.firstField { name = trimOutFieldName }.set(duration) + operationViewRef.firstMethod { name = operationCurrentTimePoint.methodName } + .invoke(currentTrimIn) + operationViewRef.firstMethod { name = "setTotalTime" }.invoke(duration) + val yVar = frameLoaderClz.firstConstructor { + parameterCount = 0 + }.create() + iRef.firstField { name = frameLoaderFieldName }.set(yVar) + clipFrameRef.firstMethod { name = "setVideoFrameLoader" }.invoke(yVar) + clipFrameRef.firstMethod { name = "setClipFrameListener" } + .invoke(iRef.firstField { name = clipListenerFieldName }.get()) + clipFrameRef.firstMethod { name = clipFrameLoadPoint.methodName }.invoke( + videoUri, + duration, + duration + ) + sInstance.asResolver().firstMethod { + name = timelinePreparePoint.methodName + parameters(Int::class.java) + }.invoke(currentTrimIn.toInt()) + state.store(true) + } - editorCfgBuilderClz.firstMethod { - name = editorConfigBuildPoint.methodName - }.hook().before { - if (!prefs.getBoolean( - ConfigKeys.HOOK_UNLOCK_VIDEO_RESTRICTIONS, - true - ) - ) return@before - val ref = instance.asResolver() - val isCallFromRearScreen = ref.field { - type = Boolean::class.java - }.all { it.get() == true } - if (isCallFromRearScreen) { - YLog.debug("Overwriting video editor max duration & frame-rate limitations") - ref.firstField { - type = Long::class.java - }.set(Long.MAX_VALUE) - ref.firstField { - type = Int::class.java - }.set(120) - } - } + // 修补帧率限制 + fpsLimitClz.firstMethod { + name = fpsLimitPoint.methodName + }.hook().replaceUnit { + if (!prefs.getBoolean(ConfigKeys.HOOK_UNLOCK_VIDEO_RESTRICTIONS, true)) { + invokeOriginal() + return@replaceUnit + } + val strF7l8 = + historyHelperClz.resolve().firstMethod { + returnType = String::class.java + parameterCount = 0 + }.invoke() as String + val iVEA = instance.asResolver().firstField { type = videoEditClz }.get()!! + val iRef = iVEA.asResolver() + val yObj = iRef.firstField { name = videoUriFieldName }.get() + val cFieldRef = iRef.firstField { name = exportPathFieldName } + cFieldRef.set( + strF7l8 + (coderUtilsRef.firstMethod { + name = coderHashPoint.methodName + }.invoke(yObj) as String) + ".mp4" + ) + val frameRetriever = + "com.xiaomi.milab.videosdk.FrameRetriever".toClass().resolve() + .firstConstructor().create().asResolver() + frameRetriever.firstMethod { name = "setDataSource" }.invoke(yObj) + val width = frameRetriever.firstMethod { name = "getWidth" }.invoke() as Int + val height = + frameRetriever.firstMethod { name = "getHeight" }.invoke() as Int + val fps = frameRetriever.firstMethod { name = "getFPS" }.invoke() as Float + val bitrate = + frameRetriever.firstMethod { name = "getBitrate" }.invoke() as Long + frameRetriever.firstMethod { name = "release" }.invoke() + if (width <= 0 || height <= 0) { + iRef.firstMethod { name = "onExportFail" }.invoke() + return@replaceUnit + } + val (outWidth, outHeight) = computeExportOutputSize(width, height, 1080) + val toqVar = exportConfigClz.firstConstructor { + parameterCount = 5 + }.create( + true, + cFieldRef.get(), + Size(outWidth, outHeight), + (((((bitrate / (width * height)) * outWidth) * outHeight) / fps) * fps).toInt(), + 0 + ) + toqVar.asResolver().firstMethod { + name = exportConfigSetFpsPoint.methodName + }.invoke(fps.toInt()) + Log.d( + "VideoEditActivity", + String.format( + "ExportConfig %s", + gsonUtilsClz.firstMethod { name = gsonSerializePoint.methodName } + .invoke(toqVar) + ) + ) + Log.d("lollipop", "export videopath is " + cFieldRef.get()) + val qRef = timelineRef.firstMethod { + name = timelineGetInstancePoint.methodName + }.invoke()!!.asResolver() + qRef.firstMethod { + name = timelineExportPoint.methodName + }.invoke( + iRef.firstField { name = trimInFieldName }.get(), + iRef.firstField { name = trimOutFieldName }.get(), + toqVar + ) + } + + editorCfgBuilderClz.firstMethod { + name = editorConfigBuildPoint.methodName + }.hook().before { + if (!prefs.getBoolean( + ConfigKeys.HOOK_UNLOCK_VIDEO_RESTRICTIONS, + true + ) + ) return@before + val ref = instance.asResolver() + val isCallFromRearScreen = ref.field { + type = Boolean::class.java + }.all { it.get() == true } + if (isCallFromRearScreen) { + YLog.debug("Overwriting video editor max duration & frame-rate limitations") + ref.firstField { + type = Long::class.java + }.set(Long.MAX_VALUE) + ref.firstField { + type = Int::class.java + }.set(120) + } + } } } diff --git a/app/src/main/java/hk/uwu/reareye/provider/RearStoreArchiveContentProvider.kt b/app/src/main/java/hk/uwu/reareye/provider/RearStoreArchiveContentProvider.kt new file mode 100644 index 0000000..ff0201c --- /dev/null +++ b/app/src/main/java/hk/uwu/reareye/provider/RearStoreArchiveContentProvider.kt @@ -0,0 +1,113 @@ +package hk.uwu.reareye.provider + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import hk.uwu.reareye.repository.rearstore.RearStoreRepository +import hk.uwu.reareye.ui.config.PrefsManager.Companion.getPrefsManager +import org.json.JSONArray +import org.json.JSONObject + +class RearStoreArchiveContentProvider : ContentProvider() { + + override fun onCreate(): Boolean = true + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor { + val context = context ?: return singleJsonCursor(buildErrorPayload("Context unavailable")) + val prefsManager = context.applicationContext.getPrefsManager() + val mode = uri.getQueryParameter("mode")?.trim().orEmpty() + val entry = uri.getQueryParameter("entry")?.trim()?.ifBlank { null } + val payload = runCatching { + val source = when (mode) { + "store_id" -> { + val widgetId = uri.getQueryParameter("id")?.trim().orEmpty() + require(widgetId.isNotBlank()) { "Missing required query parameter: id" } + RearStoreRepository.resolveInstalledArchiveSourceByStoreWidgetId( + prefsManager, + widgetId + ) + ?: error("Installed store widget not found") + } + + "business_id" -> { + val businessId = uri.getQueryParameter("id")?.trim().orEmpty() + require(businessId.isNotBlank()) { "Missing required query parameter: id" } + RearStoreRepository.resolveInstalledArchiveSourceByBusinessConfigId( + prefsManager, + businessId, + ) ?: error("Installed business config not found") + } + + else -> error("Unsupported mode: $mode") + } + + JSONObject().apply { + put("success", true) + put("error", JSONObject.NULL) + put("mode", mode) + put("storeWidgetId", source.storeWidgetId) + put("businessConfigId", source.businessConfigId) + put("business", source.businessName) + put("card", source.cardId) + put("entry", entry) + if (entry == null) { + put( + "entries", + JSONArray(RearStoreRepository.listInstalledArchiveEntries(source.filePath)) + ) + put("contentBase64", JSONObject.NULL) + } else { + put( + "contentBase64", + RearStoreRepository.readInstalledArchiveFileBase64( + filePath = source.filePath, + entryName = entry, + ) + ) + } + } + }.getOrElse { error -> + buildErrorPayload(error.message ?: "Unknown error") + } + + return singleJsonCursor(payload) + } + + override fun getType(uri: Uri): String { + return "application/json" + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 + + private fun singleJsonCursor(payload: JSONObject): Cursor { + return MatrixCursor(arrayOf("json")).apply { + addRow(arrayOf(payload.toString())) + } + } + + private fun buildErrorPayload(message: String): JSONObject { + return JSONObject().apply { + put("success", false) + put("error", message) + put("contentBase64", JSONObject.NULL) + put("entries", JSONObject.NULL) + } + } +} diff --git a/app/src/main/java/hk/uwu/reareye/repository/bounds/CustomBoundsCompatConfigCodec.kt b/app/src/main/java/hk/uwu/reareye/repository/bounds/CustomBoundsCompatConfigCodec.kt new file mode 100644 index 0000000..f128b84 --- /dev/null +++ b/app/src/main/java/hk/uwu/reareye/repository/bounds/CustomBoundsCompatConfigCodec.kt @@ -0,0 +1,207 @@ +package hk.uwu.reareye.repository.bounds + +import android.util.Base64 +import org.json.JSONArray +import org.json.JSONObject + +data class CustomBoundsCompatAppConfig( + val packageName: String, + val enabled: Boolean = true, + val mode: CustomBoundsMode = CustomBoundsMode.AUTO_RATIO, + val aspectRatio: Float = 0f, + val gravity: Int = CustomBoundsCompatConfigCodec.DEFAULT_GRAVITY, + val scale: Float = CustomBoundsCompatConfigCodec.DEFAULT_SCALE, + val densityDpi: Int = 0, + val rotationDegrees: Int = CustomBoundsCompatConfigCodec.ROTATION_FOLLOW_SYSTEM, + val insetLeft: Int = 0, + val insetTop: Int = 0, + val insetRight: Int = 0, + val insetBottom: Int = 0, + val fillEnabled: Boolean = false, + val fillMode: CustomBoundsFillMode = CustomBoundsFillMode.AUTO, + val fillColorArgb: Int = 0, +) + +enum class CustomBoundsMode { + AUTO_RATIO, + CUSTOM_RATIO, + EXACT_INSETS; + + companion object { + fun fromString(value: String?): CustomBoundsMode? { + return when (value?.trim()?.lowercase()) { + "auto_ratio" -> AUTO_RATIO + "custom_ratio" -> CUSTOM_RATIO + "exact_insets" -> EXACT_INSETS + else -> null + } + } + } +} + +enum class CustomBoundsFillMode { + AUTO, + CUSTOM; + + companion object { + fun fromString(value: String?): CustomBoundsFillMode? { + return when (value?.trim()?.lowercase()) { + "auto" -> AUTO + "custom" -> CUSTOM + else -> null + } + } + } +} + +object CustomBoundsCompatConfigCodec { + const val EMPTY_ARRAY = "[]" + const val DEFAULT_GRAVITY = 17 + const val DEFAULT_SCALE = 1.0f + const val ROTATION_FOLLOW_SYSTEM = -1 + + private val supportedRotations = setOf( + ROTATION_FOLLOW_SYSTEM, + 0, + 90, + 180, + 270, + ) + + private fun parseObject(obj: JSONObject): CustomBoundsCompatAppConfig? { + val packageName = obj.optString("packageName").trim() + if (packageName.isBlank()) return null + val aspectRatio = obj.optDouble("aspectRatio", 0.0) + .toFloat() + .takeIf { it > 0f } + ?: 0f + val parsedMode = CustomBoundsMode.fromString( + if (obj.has("mode")) obj.optString("mode") else null + ) + val mode = parsedMode ?: when { + obj.optInt("insetLeft", 0) != 0 || obj.optInt("insetTop", 0) != 0 || + obj.optInt("insetRight", 0) != 0 || obj.optInt("insetBottom", 0) != 0 -> { + CustomBoundsMode.EXACT_INSETS + } + + aspectRatio > 0f -> CustomBoundsMode.CUSTOM_RATIO + else -> CustomBoundsMode.AUTO_RATIO + } + val fillMode = CustomBoundsFillMode.fromString( + if (obj.has("fillMode")) obj.optString("fillMode") else null + ) ?: CustomBoundsFillMode.AUTO + return CustomBoundsCompatAppConfig( + packageName = packageName, + enabled = obj.optBoolean("enabled", true), + mode = mode, + aspectRatio = aspectRatio, + gravity = obj.optInt("gravity", DEFAULT_GRAVITY), + scale = obj.optDouble("scale", DEFAULT_SCALE.toDouble()) + .toFloat() + .takeIf { it > 0f } + ?: DEFAULT_SCALE, + densityDpi = obj.optInt("densityDpi", 0).coerceAtLeast(0), + rotationDegrees = normalizeRotation( + obj.optInt( + "rotationDegrees", + ROTATION_FOLLOW_SYSTEM + ) + ), + insetLeft = obj.optInt("insetLeft", 0).coerceAtLeast(0), + insetTop = obj.optInt("insetTop", 0).coerceAtLeast(0), + insetRight = obj.optInt("insetRight", 0).coerceAtLeast(0), + insetBottom = obj.optInt("insetBottom", 0).coerceAtLeast(0), + fillEnabled = obj.optBoolean("fillEnabled", false), + fillMode = fillMode, + fillColorArgb = obj.optInt("fillColorArgb", 0), + ) + } + + private fun toObject(item: CustomBoundsCompatAppConfig): JSONObject { + return JSONObject() + .put("version", 1) + .put("packageName", item.packageName.trim()) + .put("enabled", item.enabled) + .put("mode", item.mode.name.lowercase()) + .put("aspectRatio", item.aspectRatio.toDouble()) + .put("gravity", item.gravity) + .put("scale", item.scale.toDouble()) + .put("densityDpi", item.densityDpi) + .put("rotationDegrees", normalizeRotation(item.rotationDegrees)) + .put("insetLeft", item.insetLeft) + .put("insetTop", item.insetTop) + .put("insetRight", item.insetRight) + .put("insetBottom", item.insetBottom) + .put("fillEnabled", item.fillEnabled) + .put("fillMode", item.fillMode.name.lowercase()) + .put("fillColorArgb", item.fillColorArgb) + } + + fun parse(raw: String?): List { + if (raw.isNullOrBlank()) return emptyList() + val arr = runCatching { JSONArray(raw) }.getOrNull() ?: return emptyList() + val out = mutableListOf() + val seenPackages = HashSet() + for (i in 0 until arr.length()) { + val obj = arr.optJSONObject(i) ?: continue + val config = parseObject(obj) ?: continue + if (!seenPackages.add(config.packageName)) continue + out += config + } + return out + } + + fun encode(configs: List): String = + JSONArray().also { arr -> + configs + .asSequence() + .filter { it.packageName.isNotBlank() } + .distinctBy { it.packageName } + .sortedBy { it.packageName.lowercase() } + .forEach { item -> arr.put(toObject(item)) } + }.toString() + + fun encodeRuleFile(config: CustomBoundsCompatAppConfig): String { + val json = toObject(config).toString() + return Base64.encodeToString(json.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + } + + fun decodeRuleFile(raw: String?): CustomBoundsCompatAppConfig? { + if (raw.isNullOrBlank()) return null + return runCatching { + val decoded = Base64.decode(raw.trim(), Base64.DEFAULT).toString(Charsets.UTF_8) + parseObject(JSONObject(decoded)) + }.getOrNull() + } + + fun normalizeForPackages( + configs: List, + packageNames: Set, + ): List { + val byPackage = configs.associateBy { it.packageName } + return packageNames + .asSequence() + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + .sortedBy { it.lowercase() } + .map { packageName -> + byPackage[packageName] ?: CustomBoundsCompatAppConfig(packageName = packageName) + } + .toList() + } + + fun defaultAutoRatio(parentWidth: Int, parentHeight: Int): Float { + val longSide = maxOf(parentWidth, parentHeight) + val shortSide = minOf(parentWidth, parentHeight) + return if (longSide > 0) shortSide.toFloat() / longSide else 1f + } + + fun normalizeRotation(rotationDegrees: Int): Int { + return if (rotationDegrees in supportedRotations) { + rotationDegrees + } else { + ROTATION_FOLLOW_SYSTEM + } + } +} diff --git a/app/src/main/java/hk/uwu/reareye/repository/rearstore/RearStoreRepository.kt b/app/src/main/java/hk/uwu/reareye/repository/rearstore/RearStoreRepository.kt index 1a77fb3..0da2a4f 100644 --- a/app/src/main/java/hk/uwu/reareye/repository/rearstore/RearStoreRepository.kt +++ b/app/src/main/java/hk/uwu/reareye/repository/rearstore/RearStoreRepository.kt @@ -1,11 +1,15 @@ package hk.uwu.reareye.repository.rearstore import android.content.Context +import android.content.pm.PackageManager import android.net.Uri +import android.util.Base64 import androidx.annotation.Keep +import androidx.core.content.ContextCompat import com.google.gson.Gson import com.google.gson.annotations.SerializedName import hk.uwu.reareye.BuildConfig +import hk.uwu.reareye.R import hk.uwu.reareye.repository.rearwallpaper.RearWallpaperMetadataOptions import hk.uwu.reareye.repository.rearwallpaper.RearWallpaperOperationResult import hk.uwu.reareye.repository.rearwallpaper.RearWallpaperRepository @@ -14,8 +18,14 @@ import hk.uwu.reareye.repository.rearwidget.RearCardConfig import hk.uwu.reareye.repository.rearwidget.RearWidgetConfigCodec import hk.uwu.reareye.repository.rearwidget.RearWidgetManagerRepository import hk.uwu.reareye.repository.rearwidget.RearWidgetSceneRouteConfig +import hk.uwu.reareye.ui.config.ConfigCategory +import hk.uwu.reareye.ui.config.ConfigGroup +import hk.uwu.reareye.ui.config.ConfigItem import hk.uwu.reareye.ui.config.ConfigKeys +import hk.uwu.reareye.ui.config.ConfigNode +import hk.uwu.reareye.ui.config.ConfigType import hk.uwu.reareye.ui.config.PrefsManager +import hk.uwu.reareye.ui.config.REAREyeConfig import hk.uwu.reareye.ui.config.resolveRearStoreApiBaseUrl import hk.uwu.reareye.widgetapi.RearWidgetSceneRouteSpec import kotlinx.coroutines.Dispatchers @@ -31,15 +41,69 @@ import okhttp3.logging.HttpLoggingInterceptor import org.json.JSONArray import org.json.JSONObject import java.io.ByteArrayOutputStream +import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone +import java.util.zip.ZipFile import java.util.zip.ZipInputStream private const val DEFAULT_COMPONENT_ROUTE_PACKAGE = "com.xiaomi.subscreencenter" +private const val MIUI_GET_INSTALLED_APPS_PERMISSION = "com.android.permission.GET_INSTALLED_APPS" private const val WIDGET_VERSION_CHECK_DISABLED = -1L +@Keep +data class RearStoreWidgetRequirements( + @SerializedName("packages") + val packages: List = emptyList(), + @SerializedName("configs") + val configs: Map = emptyMap(), +) + +@Keep +data class RearStorePostInstall( + @SerializedName("uri") + val uri: String = "", +) + +data class RearStoreWidgetRequirementsResult( + val satisfied: Boolean, + val appListPermissionGranted: Boolean = true, + val missingPackages: List = emptyList(), + val failedConfigKeys: List = emptyList(), + val failedConfigRequirements: Map = emptyMap(), + val failedConfigReasons: Map = emptyMap(), +) + +private data class RearStoreConfigNodeIndex( + val titleResByKey: Map, + val itemTypeByKey: Map, +) + +private enum class RearStoreRequirementOperator(val token: String) { + GTE(">="), + LTE("<="), + EQ("=="), + NE("!="), + GT(">"), + LT("<"); + + companion object { + private val ordered = entries.sortedByDescending { it.token.length } + + fun parse(expression: String): Pair { + val normalized = expression.trim() + ordered.forEach { operator -> + if (normalized.startsWith(operator.token)) { + return operator to normalized.removePrefix(operator.token).trim() + } + } + return EQ to normalized + } + } +} + private fun String?.normalizedOrNull(): String? { return this?.trim()?.takeIf { it.isNotEmpty() } } @@ -94,10 +158,475 @@ fun RearStoreWidgetInfo?.supportsModuleVersion( (maxVersion == WIDGET_VERSION_CHECK_DISABLED || versionCode <= maxVersion) } +fun RearStoreWidgetInfo?.evaluateRequirements( + context: Context, + prefsManager: PrefsManager, +): RearStoreWidgetRequirementsResult { + val requirements = this?.requirements + val requiredPackages = requirements.normalizedPackages() + val requiredConfigs = requirements.normalizedConfigExpressions() + val configNodeIndex = buildConfigNodeIndex() + if (requiredPackages.isEmpty() && requiredConfigs.isEmpty()) { + return RearStoreWidgetRequirementsResult(satisfied = true) + } + + val installedPackages = if (requiredPackages.isEmpty()) { + emptySet() + } else { + loadInstalledPackagesOrNull(context) + } + val missingPackages = when { + requiredPackages.isEmpty() -> emptyList() + installedPackages == null -> requiredPackages + else -> requiredPackages.filterNot(installedPackages::contains) + } + val failedConfigReasons = linkedMapOf() + requiredConfigs.forEach { (key, expression) -> + if (!configNodeIndex.titleResByKey.containsKey(key)) { + failedConfigReasons[key] = context.getString( + R.string.rear_store_requirement_config_unknown_item, + key, + ) + return@forEach + } + val actualValue = prefsManager.getRequirementValue(key) + val result = evaluateConfigRequirement( + context = context, + key = key, + actualValue = actualValue, + expression = expression, + configNodeIndex = configNodeIndex, + ) + if (!result.satisfied) { + failedConfigReasons[key] = result.reason + } + } + val failedConfigKeys = failedConfigReasons.keys.toList() + + return RearStoreWidgetRequirementsResult( + satisfied = missingPackages.isEmpty() && failedConfigKeys.isEmpty(), + appListPermissionGranted = requiredPackages.isEmpty() || installedPackages != null, + missingPackages = missingPackages, + failedConfigKeys = failedConfigKeys, + failedConfigRequirements = requiredConfigs.filterKeys(failedConfigKeys::contains), + failedConfigReasons = failedConfigReasons, + ) +} + fun RearStoreWidgetMetadata?.resolvedType(): RearStoreWidgetMetadataType { return RearStoreWidgetMetadataType.fromRaw(this?.type) } +private fun RearStoreWidgetRequirements?.normalizedPackages(): List { + return this?.packages + ?.mapNotNull(String::normalizedOrNull) + ?.distinct() + .orEmpty() +} + +private fun RearStoreWidgetRequirements?.normalizedConfigExpressions(): Map { + return this?.configs + ?.mapNotNull { (key, value) -> + val normalizedKey = key.normalizedOrNull() ?: return@mapNotNull null + val expression = value.toRequirementExpression() ?: return@mapNotNull null + normalizedKey to expression + } + ?.toMap(linkedMapOf()) + .orEmpty() +} + +private fun Any?.toRequirementExpression(): String? { + return when (this) { + null -> null + is String -> normalizedOrNull() + is Boolean, is Number -> "== $this" + is Collection<*> -> null + else -> toString().normalizedOrNull() + } +} + +private fun loadInstalledPackagesOrNull(context: Context): Set? { + val canReadAppList = ContextCompat.checkSelfPermission( + context, + MIUI_GET_INSTALLED_APPS_PERMISSION, + ) == PackageManager.PERMISSION_GRANTED + if (!canReadAppList) return null + return runCatching { + context.packageManager + .getInstalledApplications(PackageManager.GET_META_DATA) + .mapNotNullTo(linkedSetOf()) { it.packageName.normalizedOrNull() } + }.getOrNull() +} + +private data class RearStoreRequirementCheckResult( + val satisfied: Boolean, + val reason: String, +) + +private fun evaluateConfigRequirement( + context: Context, + key: String, + actualValue: Any?, + expression: String, + configNodeIndex: RearStoreConfigNodeIndex, +): RearStoreRequirementCheckResult { + if (actualValue == null) { + return RearStoreRequirementCheckResult( + satisfied = false, + reason = buildConfigMissingReason(context, key, configNodeIndex), + ) + } + val (operator, operand) = RearStoreRequirementOperator.parse(expression) + return when (actualValue) { + is Boolean -> evaluateBooleanRequirement( + context, + key, + actualValue, + operator, + operand, + configNodeIndex + ) + + is Int -> evaluateNumericRequirement( + context, + key, + actualValue.toDouble(), + operator, + operand, + configNodeIndex + ) + + is Long -> evaluateNumericRequirement( + context, + key, + actualValue.toDouble(), + operator, + operand, + configNodeIndex + ) + + is Float -> evaluateNumericRequirement( + context, + key, + actualValue.toDouble(), + operator, + operand, + configNodeIndex + ) + + is Double -> evaluateNumericRequirement( + context, + key, + actualValue, + operator, + operand, + configNodeIndex + ) + + is Set<*> -> evaluateCollectionRequirement( + context, + key, + actualValue, + operator, + operand, + configNodeIndex + ) + + is Collection<*> -> evaluateCollectionRequirement( + context, + key, + actualValue, + operator, + operand, + configNodeIndex + ) + + else -> evaluateStringRequirement( + context, + key, + actualValue.toString(), + operator, + operand, + configNodeIndex + ) + } +} + +private fun evaluateBooleanRequirement( + context: Context, + key: String, + actual: Boolean, + operator: RearStoreRequirementOperator, + operand: String, + configNodeIndex: RearStoreConfigNodeIndex, +): RearStoreRequirementCheckResult { + val expected = operand.toBooleanStrictOrNull() + val satisfied = when (operator) { + RearStoreRequirementOperator.EQ -> expected != null && actual == expected + RearStoreRequirementOperator.NE -> expected != null && actual != expected + else -> false + } + if (satisfied) return RearStoreRequirementCheckResult(true, "") + + val reason = when { + expected == true && operator == RearStoreRequirementOperator.EQ -> { + buildConfigNeedEnabledReason(context, key, configNodeIndex) + } + + expected == false && operator == RearStoreRequirementOperator.EQ -> { + buildConfigNeedDisabledReason(context, key, configNodeIndex) + } + + expected == true && operator == RearStoreRequirementOperator.NE -> { + buildConfigNotEnabledReason(context, key, configNodeIndex) + } + + expected == false && operator == RearStoreRequirementOperator.NE -> { + buildConfigNotDisabledReason(context, key, configNodeIndex) + } + + else -> buildConfigNeedEnabledReason(context, key, configNodeIndex) + } + return RearStoreRequirementCheckResult(false, reason) +} + +private fun buildConfigNodeIndex(): RearStoreConfigNodeIndex { + val titleResByKey = linkedMapOf() + val itemTypeByKey = linkedMapOf() + + fun walk(nodes: List) { + nodes.forEach { node -> + when (node) { + is ConfigItem -> { + titleResByKey[node.key] = node.titleRes + itemTypeByKey[node.key] = node.type + } + + is ConfigCategory -> walk(node.children) + is ConfigGroup -> walk(node.children) + } + } + } + + walk(REAREyeConfig) + return RearStoreConfigNodeIndex(titleResByKey = titleResByKey, itemTypeByKey = itemTypeByKey) +} + +private fun buildConfigMissingReason( + context: Context, + key: String, + configNodeIndex: RearStoreConfigNodeIndex, +): String { + val titleRes = configNodeIndex.titleResByKey[key] + return if (titleRes != null) { + context.getString( + R.string.rear_store_requirement_config_missing_named_item, + quotedConfigTitle(context.getString(titleRes)), + ) + } else { + context.getString(R.string.rear_store_requirement_config_unknown_item, key) + } +} + +private fun quotedConfigTitle(title: String): String { + return "\"$title\"" +} + +private fun buildConfigNeedEnabledReason( + context: Context, + key: String, + configNodeIndex: RearStoreConfigNodeIndex, +): String { + val title = + configNodeIndex.titleResByKey[key]?.let(context::getString)?.let(::quotedConfigTitle) + .orEmpty() + return context.getString(R.string.rear_store_requirement_config_need_enabled_named_item, title) +} + +private fun buildConfigNeedDisabledReason( + context: Context, + key: String, + configNodeIndex: RearStoreConfigNodeIndex, +): String { + val title = + configNodeIndex.titleResByKey[key]?.let(context::getString)?.let(::quotedConfigTitle) + .orEmpty() + return context.getString(R.string.rear_store_requirement_config_need_disabled_named_item, title) +} + +private fun buildConfigNotEnabledReason( + context: Context, + key: String, + configNodeIndex: RearStoreConfigNodeIndex, +): String { + val title = + configNodeIndex.titleResByKey[key]?.let(context::getString)?.let(::quotedConfigTitle) + .orEmpty() + return context.getString(R.string.rear_store_requirement_config_not_enabled_named_item, title) +} + +private fun buildConfigNotDisabledReason( + context: Context, + key: String, + configNodeIndex: RearStoreConfigNodeIndex, +): String { + val title = + configNodeIndex.titleResByKey[key]?.let(context::getString)?.let(::quotedConfigTitle) + .orEmpty() + return context.getString(R.string.rear_store_requirement_config_not_disabled_named_item, title) +} + +private fun evaluateNumericRequirement( + context: Context, + key: String, + actual: Double, + operator: RearStoreRequirementOperator, + operand: String, + configNodeIndex: RearStoreConfigNodeIndex, +): RearStoreRequirementCheckResult { + val expected = operand.toDoubleOrNull() + val satisfied = when (operator) { + RearStoreRequirementOperator.GTE -> expected != null && actual >= expected + RearStoreRequirementOperator.LTE -> expected != null && actual <= expected + RearStoreRequirementOperator.EQ -> expected != null && actual == expected + RearStoreRequirementOperator.NE -> expected != null && actual != expected + RearStoreRequirementOperator.GT -> expected != null && actual > expected + RearStoreRequirementOperator.LT -> expected != null && actual < expected + } + if (satisfied) return RearStoreRequirementCheckResult(true, "") + + val reason = when (operator) { + RearStoreRequirementOperator.GTE -> context.getString( + R.string.rear_store_requirement_config_number_gte_item, + configNodeIndex.titleResByKey[key]?.let(context::getString)?.let(::quotedConfigTitle) + .orEmpty(), + operand, + ) + + RearStoreRequirementOperator.LTE -> context.getString( + R.string.rear_store_requirement_config_number_lte_item, + configNodeIndex.titleResByKey[key]?.let(context::getString)?.let(::quotedConfigTitle) + .orEmpty(), + operand, + ) + + RearStoreRequirementOperator.EQ -> context.getString( + R.string.rear_store_requirement_config_number_eq_item, + configNodeIndex.titleResByKey[key]?.let(context::getString)?.let(::quotedConfigTitle) + .orEmpty(), + operand, + ) + + RearStoreRequirementOperator.NE -> context.getString( + R.string.rear_store_requirement_config_number_ne_item, + configNodeIndex.titleResByKey[key]?.let(context::getString)?.let(::quotedConfigTitle) + .orEmpty(), + operand, + ) + + RearStoreRequirementOperator.GT -> context.getString( + R.string.rear_store_requirement_config_number_gt_item, + configNodeIndex.titleResByKey[key]?.let(context::getString)?.let(::quotedConfigTitle) + .orEmpty(), + operand, + ) + + RearStoreRequirementOperator.LT -> context.getString( + R.string.rear_store_requirement_config_number_lt_item, + configNodeIndex.titleResByKey[key]?.let(context::getString)?.let(::quotedConfigTitle) + .orEmpty(), + operand, + ) + } + return RearStoreRequirementCheckResult(false, reason) +} + +private fun evaluateCollectionRequirement( + context: Context, + key: String, + actual: Collection<*>, + operator: RearStoreRequirementOperator, + operand: String, + configNodeIndex: RearStoreConfigNodeIndex, +): RearStoreRequirementCheckResult { + val normalizedValues = actual.mapNotNull { it?.toString().normalizedOrNull() }.toSet() + val target = operand.normalizedOrNull() ?: return RearStoreRequirementCheckResult( + satisfied = false, + reason = buildConfigNeedEnabledReason(context, key, configNodeIndex), + ) + val satisfied = when (operator) { + RearStoreRequirementOperator.EQ -> target in normalizedValues + RearStoreRequirementOperator.NE -> target !in normalizedValues + else -> false + } + if (satisfied) return RearStoreRequirementCheckResult(true, "") + + val reason = when (operator) { + RearStoreRequirementOperator.EQ -> context.getString( + R.string.rear_store_requirement_config_contains_item, + configNodeIndex.titleResByKey[key]?.let(context::getString)?.let(::quotedConfigTitle) + .orEmpty(), + target, + ) + + RearStoreRequirementOperator.NE -> context.getString( + R.string.rear_store_requirement_config_not_contains_item, + configNodeIndex.titleResByKey[key]?.let(context::getString)?.let(::quotedConfigTitle) + .orEmpty(), + target, + ) + + else -> context.getString( + R.string.rear_store_requirement_config_contains_item, + configNodeIndex.titleResByKey[key]?.let(context::getString)?.let(::quotedConfigTitle) + .orEmpty(), + target, + ) + } + return RearStoreRequirementCheckResult(false, reason) +} + +private fun evaluateStringRequirement( + context: Context, + key: String, + actual: String, + operator: RearStoreRequirementOperator, + operand: String, + configNodeIndex: RearStoreConfigNodeIndex, +): RearStoreRequirementCheckResult { + val normalizedActual = actual.trim() + val satisfied = when (operator) { + RearStoreRequirementOperator.EQ -> normalizedActual == operand + RearStoreRequirementOperator.NE -> normalizedActual != operand + else -> false + } + if (satisfied) return RearStoreRequirementCheckResult(true, "") + + val reason = when (operator) { + RearStoreRequirementOperator.EQ -> context.getString( + R.string.rear_store_requirement_config_string_eq_item, + configNodeIndex.titleResByKey[key]?.let(context::getString)?.let(::quotedConfigTitle) + .orEmpty(), + operand, + ) + + RearStoreRequirementOperator.NE -> context.getString( + R.string.rear_store_requirement_config_string_ne_item, + configNodeIndex.titleResByKey[key]?.let(context::getString)?.let(::quotedConfigTitle) + .orEmpty(), + operand, + ) + + else -> context.getString( + R.string.rear_store_requirement_config_string_eq_item, + configNodeIndex.titleResByKey[key]?.let(context::getString)?.let(::quotedConfigTitle) + .orEmpty(), + operand, + ) + } + return RearStoreRequirementCheckResult(false, reason) +} + private fun parseMetadataType(rawType: String?): RearStoreWidgetMetadataType { val raw = rawType?.trim().orEmpty() if (raw.contains("壁纸")) return RearStoreWidgetMetadataType.WALLPAPER @@ -298,6 +827,10 @@ data class RearStoreWidgetInfo( val minVersion: Long = WIDGET_VERSION_CHECK_DISABLED, @SerializedName(value = "maxVersion", alternate = ["max_version"]) val maxVersion: Long = WIDGET_VERSION_CHECK_DISABLED, + @SerializedName("requirements") + val requirements: RearStoreWidgetRequirements? = null, + @SerializedName("postinstall") + val postInstall: RearStorePostInstall? = null, ) @Keep @@ -369,6 +902,8 @@ data class RearStoreRelease( val publishedAt: String = "", @SerializedName("url") val url: String = "", + @SerializedName(value = "widgetInfo", alternate = ["widget_info"]) + val widgetInfo: RearStoreWidgetInfo? = null, @SerializedName("assets") val assets: List = emptyList(), ) @@ -435,6 +970,16 @@ data class RearStoreQuickInstallResult( val cardInstalled: Boolean, val fallbackUsed: Boolean, val updatedExistingInstall: Boolean, + val businessConfigId: String? = null, + val cardId: String? = null, +) + +data class RearStoreInstalledArchiveSource( + val storeWidgetId: String?, + val businessConfigId: String, + val businessName: String, + val cardId: String?, + val filePath: String, ) data class RearStorePreparedInstallAsset( @@ -585,18 +1130,19 @@ object RearStoreRepository { suspend fun loadWidgetDetail( prefsManager: PrefsManager, - widgetId: String + widgetId: String, + widgetInfoVersion: String? = null, ): RearStoreWidgetDetail? { val normalizedWidgetId = widgetId.normalizedOrNull() ?: return null + val normalizedWidgetInfoVersion = widgetInfoVersion.normalizedOrNull() val baseUrl = resolveRearStoreApiBaseUrl(prefsManager) - val detailCacheKey = cacheKey(baseUrl, normalizedWidgetId) + val detailCacheKey = cacheKey(baseUrl, normalizedWidgetId, normalizedWidgetInfoVersion) detailCache[detailCacheKey]?.let { return it } return withContext(Dispatchers.IO) { coroutineScope { val descriptionDeferred = async { loadDescriptionCached(baseUrl, normalizedWidgetId) } val authorDeferred = async { loadAuthorCached(baseUrl, normalizedWidgetId) } - val infoDeferred = async { loadWidgetInfoCached(baseUrl, normalizedWidgetId) } val releasesDeferred = async { loadReleasesCached(baseUrl, normalizedWidgetId) } val description = descriptionDeferred.await() @@ -607,12 +1153,17 @@ object RearStoreRepository { } ?: return@coroutineScope null val repository = description.repository - val widgetInfo = infoDeferred.await() + val releases = releasesDeferred.await().orEmpty() + val widgetInfo = loadWidgetInfoForVersionCached( + baseUrl = baseUrl, + widgetId = normalizedWidgetId, + version = normalizedWidgetInfoVersion, + releases = releases, + ) val metadata = description.metadata - val widgetType = loadWidgetTypeFromInfoCached(baseUrl, normalizedWidgetId) val resolvedAuthor = resolveAuthor(authorDeferred.await(), repository) val detail = RearStoreWidgetDetail( - type = widgetType ?: metadata?.type.normalizedOrNull(), + type = widgetInfo?.type.normalizedOrNull() ?: metadata?.type.normalizedOrNull(), widgetId = normalizedWidgetId, name = description.name.normalizedOrNull() ?: widgetInfo?.name.normalizedOrNull() @@ -623,7 +1174,7 @@ object RearStoreRepository { widgetInfo = widgetInfo, metadata = metadata, readme = readmeCache[detailCacheKey], - releases = releasesDeferred.await().orEmpty(), + releases = releases, ) detailCache[detailCacheKey] = detail detail @@ -631,6 +1182,22 @@ object RearStoreRepository { } } + suspend fun loadWidgetDetailForRelease( + prefsManager: PrefsManager, + detail: RearStoreWidgetDetail, + releaseTag: String?, + ): RearStoreWidgetDetail = withContext(Dispatchers.IO) { + val normalizedWidgetId = detail.widgetId.normalizedOrNull() ?: return@withContext detail + val baseUrl = resolveRearStoreApiBaseUrl(prefsManager) + val widgetInfo = loadWidgetInfoForVersionCached( + baseUrl = baseUrl, + widgetId = normalizedWidgetId, + version = releaseTag, + releases = detail.releases, + ) + detail.copy(widgetInfo = widgetInfo ?: detail.widgetInfo) + } + suspend fun loadWidgetReadme(prefsManager: PrefsManager, widgetId: String): RearStoreReadme? { val normalizedWidgetId = widgetId.normalizedOrNull() ?: return null val baseUrl = resolveRearStoreApiBaseUrl(prefsManager) @@ -693,6 +1260,85 @@ object RearStoreRepository { } } + fun resolveInstalledArchiveSourceByStoreWidgetId( + prefsManager: PrefsManager, + widgetId: String, + ): RearStoreInstalledArchiveSource? { + val normalizedWidgetId = widgetId.normalizedOrNull() ?: return null + val businesses = RearWidgetManagerRepository.loadBusinesses(prefsManager) + val cards = RearWidgetManagerRepository.loadCards(prefsManager) + val business = businesses.firstOrNull { + it.downloadedFromStore && + it.storeWidgetId.normalizedOrNull() == normalizedWidgetId && + it.filePath.normalizedOrNull() != null + } ?: return null + val cardId = cards.firstOrNull { + it.downloadedFromStore && it.storeWidgetId.normalizedOrNull() == normalizedWidgetId + }?.id + return RearStoreInstalledArchiveSource( + storeWidgetId = normalizedWidgetId, + businessConfigId = business.id, + businessName = business.business, + cardId = cardId, + filePath = business.filePath, + ) + } + + fun resolveInstalledArchiveSourceByBusinessConfigId( + prefsManager: PrefsManager, + businessConfigId: String, + ): RearStoreInstalledArchiveSource? { + val normalizedBusinessConfigId = businessConfigId.normalizedOrNull() ?: return null + val businesses = RearWidgetManagerRepository.loadBusinesses(prefsManager) + val cards = RearWidgetManagerRepository.loadCards(prefsManager) + val business = businesses.firstOrNull { + it.id.normalizedOrNull() == normalizedBusinessConfigId && it.filePath.normalizedOrNull() != null + } ?: return null + val cardId = cards.firstOrNull { + it.business == business.business && + it.storeWidgetId.normalizedOrNull() == business.storeWidgetId.normalizedOrNull() + }?.id + return RearStoreInstalledArchiveSource( + storeWidgetId = business.storeWidgetId.normalizedOrNull(), + businessConfigId = business.id, + businessName = business.business, + cardId = cardId, + filePath = business.filePath, + ) + } + + fun readInstalledArchiveFileBase64( + filePath: String, + entryName: String? = null, + ): String { + val targetFile = File(filePath) + require(targetFile.exists() && targetFile.isFile) { "Archive file not found" } + val bytes = if (entryName.normalizedOrNull() == null) { + targetFile.readBytes() + } else { + readZipEntryBytes(targetFile, entryName.normalizedOrNull()!!) + ?: error("Entry '$entryName' not found in archive") + } + return Base64.encodeToString(bytes, Base64.NO_WRAP) + } + + fun listInstalledArchiveEntries(filePath: String): List { + val targetFile = File(filePath) + require(targetFile.exists() && targetFile.isFile) { "Archive file not found" } + return runCatching { + ZipFile(targetFile).use { zipFile -> + buildList { + val entries = zipFile.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (entry.isDirectory) continue + add(entry.name.replace('\\', '/').trimStart('/')) + } + }.sorted() + } + }.getOrElse { emptyList() } + } + fun loadInstalledWallpaperSources( prefsManager: PrefsManager, ): Map { @@ -855,6 +1501,11 @@ object RearStoreRepository { preferredAssetName = assetName, ) ?: error("No downloadable release asset found") + val installDetail = loadWidgetDetailForRelease( + prefsManager = prefsManager, + detail = detail, + releaseTag = selectedAsset.release.tagName, + ) val assetBytes = downloadAssetBytes( baseUrl = baseUrl, widgetId = detail.widgetId, @@ -862,10 +1513,11 @@ object RearStoreRepository { onProgress = onProgress, ) ?: error("Failed to download widget asset") - val isWallpaper = detail.widgetInfo.resolvedType() == RearStoreWidgetInfoType.WALLPAPER + val isWallpaper = + installDetail.widgetInfo.resolvedType() == RearStoreWidgetInfoType.WALLPAPER val embeddedMetadataBytes = if (isWallpaper) extractRootMetadataMrm(assetBytes) else null val defaultWallpaperOptions = if (isWallpaper) { - detail.toWallpaperMetadataOptions(detail.defaultWallpaperName()) + installDetail.toWallpaperMetadataOptions(installDetail.defaultWallpaperName()) } else { null } @@ -897,20 +1549,63 @@ object RearStoreRepository { wallpaperMetadataOptions: RearWallpaperMetadataOptions? = null, onProgress: (RearStoreInstallProgress) -> Unit = {}, ): RearStoreQuickInstallResult = withContext(Dispatchers.IO) { - val widgetInfoType = detail.widgetInfo.resolvedType() + val selectedReleaseTag = preparedAsset?.release?.tagName.normalizedOrNull() + ?: releaseTag.normalizedOrNull() + ?: selectAsset( + releases = detail.releases, + preferredReleaseTag = releaseTag, + preferredAssetName = assetName, + )?.release?.tagName.normalizedOrNull() + val installDetail = loadWidgetDetailForRelease( + prefsManager = prefsManager, + detail = detail, + releaseTag = selectedReleaseTag, + ) + val widgetInfoType = installDetail.widgetInfo.resolvedType() if (!widgetInfoType.supportedInCurrentVersion) { error("Install mode '${widgetInfoType.rawValue}' is not supported by current version") } - if (!detail.widgetInfo.supportsModuleVersion()) { + if (!installDetail.widgetInfo.supportsModuleVersion()) { error("Current module version does not support installing this component") } - val conflict = resolveInstallConflict(prefsManager, detail) + val requirementsResult = + installDetail.widgetInfo.evaluateRequirements(context, prefsManager) + if (!requirementsResult.satisfied) { + when { + !requirementsResult.appListPermissionGranted && requirementsResult.missingPackages.isNotEmpty() -> { + error("Installed apps permission is required to verify widget package requirements") + } + + requirementsResult.missingPackages.isNotEmpty() -> { + error( + "Missing required packages: ${ + requirementsResult.missingPackages.joinToString( + ", " + ) + }" + ) + } + + requirementsResult.failedConfigKeys.isNotEmpty() -> { + error( + "Unsatisfied required configs: ${ + requirementsResult.failedConfigKeys.joinToString( + ", " + ) + }" + ) + } + + else -> error("Widget requirements are not satisfied") + } + } + val conflict = resolveInstallConflict(prefsManager, installDetail) if (conflict != null && !forceOverwrite) { error(buildInstallConflictMessage(conflict)) } val prepared = preparedAsset ?: prepareInstallAsset( prefsManager = prefsManager, - detail = detail, + detail = installDetail, releaseTag = releaseTag, assetName = assetName, onProgress = onProgress, @@ -924,7 +1619,7 @@ object RearStoreRepository { RearStoreWidgetInfoType.WIDGET -> installWidgetAsset( context = context, prefsManager = prefsManager, - detail = detail, + detail = installDetail, selectedAsset = RearStoreSelectedAsset(prepared.release, prepared.asset), assetBytes = prepared.assetBytes, conflict = conflict, @@ -933,7 +1628,7 @@ object RearStoreRepository { RearStoreWidgetInfoType.WALLPAPER -> installWallpaperAsset( context = context, prefsManager = prefsManager, - detail = detail, + detail = installDetail, selectedAsset = RearStoreSelectedAsset(prepared.release, prepared.asset), assetBytes = prepared.assetBytes, metadataBytes = prepared.embeddedMetadataBytes, @@ -1001,34 +1696,33 @@ object RearStoreRepository { (conflictingStoreWidgetId != null && it.storeWidgetId.normalizedOrNull() == conflictingStoreWidgetId) } + val installedBusiness = RearBusinessConfig( + id = previousBusiness?.id + ?: RearWidgetConfigCodec.newBusinessId( + DEFAULT_COMPONENT_ROUTE_PACKAGE, + businessId, + ), + packageName = DEFAULT_COMPONENT_ROUTE_PACKAGE, + business = businessId, + filePath = targetPath, + defaultIndex = previousBusiness?.defaultIndex ?: 0, + defaultPriority = previousBusiness?.defaultPriority ?: 500, + renameable = businessSetup?.renameable ?: true, + downloadedFromStore = true, + storeWidgetId = detail.widgetId, + storeWidgetName = businessName, + storeReleaseTag = storeReleaseTag, + storeReleaseAssetName = storeReleaseAssetName, + storeReleasePublishedAt = storeReleasePublishedAt, + storeInstalledAt = installedAt, + ) val nextBusinesses = businesses .filterNot { it.matchesStoreBusiness(detail.widgetId, businessId) || (conflictingStoreWidgetId != null && it.storeWidgetId.normalizedOrNull() == conflictingStoreWidgetId) } - .plus( - RearBusinessConfig( - id = previousBusiness?.id - ?: RearWidgetConfigCodec.newBusinessId( - DEFAULT_COMPONENT_ROUTE_PACKAGE, - businessId, - ), - packageName = DEFAULT_COMPONENT_ROUTE_PACKAGE, - business = businessId, - filePath = targetPath, - defaultIndex = previousBusiness?.defaultIndex ?: 0, - defaultPriority = previousBusiness?.defaultPriority ?: 500, - renameable = businessSetup?.renameable ?: true, - downloadedFromStore = true, - storeWidgetId = detail.widgetId, - storeWidgetName = businessName, - storeReleaseTag = storeReleaseTag, - storeReleaseAssetName = storeReleaseAssetName, - storeReleasePublishedAt = storeReleasePublishedAt, - storeInstalledAt = installedAt, - ) - ) + .plus(installedBusiness) RearWidgetManagerRepository.saveBusinesses( context = context, prefsManager = prefsManager, @@ -1097,6 +1791,7 @@ object RearStoreRepository { } } var cardInstalled = false + var installedCardId: String? = null val nextCards = cards .filterNot { (cardPackage != null && it.matchesStoreCard( @@ -1111,7 +1806,7 @@ object RearStoreRepository { val cardSetup = detail.widgetInfo?.cardSetup ?: return@let existingCards val normalizedCardPackage = cardPackage ?: return@let existingCards cardInstalled = true - existingCards + RearCardConfig( + val installedCard = RearCardConfig( id = previousCard?.id ?: RearWidgetConfigCodec.newCardId(), title = cardSetup.name.normalizedOrNull() ?: businessName, packageName = normalizedCardPackage, @@ -1133,6 +1828,8 @@ object RearStoreRepository { storeReleaseAssetName = storeReleaseAssetName, storeReleasePublishedAt = storeReleasePublishedAt, ) + installedCardId = installedCard.id + existingCards + installedCard } if (nextCards != cards) { RearWidgetManagerRepository.saveCards( @@ -1150,6 +1847,8 @@ object RearStoreRepository { cardInstalled = cardInstalled, fallbackUsed = false, updatedExistingInstall = previousBusiness != null || conflict != null, + businessConfigId = installedBusiness.id, + cardId = installedCardId, ) } @@ -1222,6 +1921,8 @@ object RearStoreRepository { cardInstalled = false, fallbackUsed = false, updatedExistingInstall = previousRecords.isNotEmpty(), + businessConfigId = null, + cardId = null, ) } @@ -1390,6 +2091,19 @@ object RearStoreRepository { return optString(key).trim().takeIf { it.isNotBlank() } } + private fun readZipEntryBytes(file: File, entryName: String): ByteArray? { + val normalizedEntryName = entryName.replace('\\', '/').trimStart('/') + return runCatching { + ZipFile(file).use { zipFile -> + val entry = zipFile.entries().asSequence().firstOrNull { + !it.isDirectory && it.name.replace('\\', '/') + .trimStart('/') == normalizedEntryName + } ?: return@runCatching null + zipFile.getInputStream(entry).use { input -> input.readBytes() } + } + }.getOrNull() + } + private suspend fun fetchBytes( url: String, onProgress: (RearStoreInstallProgress) -> Unit, @@ -1516,6 +2230,38 @@ object RearStoreRepository { return loadedWidgetInfo ?: widgetInfoCache[cacheKey] } + private fun loadWidgetInfoForVersionCached( + baseUrl: String, + widgetId: String, + version: String?, + releases: List = emptyList(), + ): RearStoreWidgetInfo? { + val normalizedVersion = version.normalizedOrNull() + ?: return loadWidgetInfoCached(baseUrl, widgetId) + val cacheKey = cacheKey(baseUrl, widgetId, normalizedVersion) + widgetInfoCache[cacheKey]?.let { return it } + + val releaseWidgetInfo = releases.firstOrNull { release -> + release.tagName.normalizedOrNull() == normalizedVersion || + release.name.normalizedOrNull() == normalizedVersion + }?.widgetInfo + if (releaseWidgetInfo != null) { + widgetInfoCache[cacheKey] = releaseWidgetInfo + return releaseWidgetInfo + } + + val loadedWidgetInfo = fetchJson( + baseUrl, + "/widget/${Uri.encode(widgetId)}/releases/${Uri.encode(normalizedVersion)}/widget-info" + )?.widgetInfo + if (loadedWidgetInfo != null) { + widgetInfoCache[cacheKey] = loadedWidgetInfo + return loadedWidgetInfo + } + + return loadWidgetInfoCached(baseUrl, widgetId) + } + private fun loadWidgetTypeFromInfoCached(baseUrl: String, widgetId: String): String? { val cacheKey = cacheKey(baseUrl, widgetId) widgetTypeCache[cacheKey]?.let { return it } @@ -1581,6 +2327,11 @@ object RearStoreRepository { return "$baseUrl\u0000$widgetId" } + private fun cacheKey(baseUrl: String, widgetId: String, version: String?): String { + val normalizedVersion = version.normalizedOrNull() ?: return cacheKey(baseUrl, widgetId) + return "$baseUrl\u0000$widgetId\u0000$normalizedVersion" + } + private fun selectAsset( releases: List, preferredReleaseTag: String? = null, diff --git a/app/src/main/java/hk/uwu/reareye/service/BaseTileService.kt b/app/src/main/java/hk/uwu/reareye/service/BaseTileService.kt index 1b1feec..475af52 100644 --- a/app/src/main/java/hk/uwu/reareye/service/BaseTileService.kt +++ b/app/src/main/java/hk/uwu/reareye/service/BaseTileService.kt @@ -1,17 +1,14 @@ package hk.uwu.reareye.service -import android.os.Build import android.os.Handler import android.os.Looper import android.service.quicksettings.Tile import android.service.quicksettings.TileService import android.util.Log import android.widget.Toast -import androidx.annotation.RequiresApi import hk.uwu.reareye.R import hk.uwu.reareye.utils.RootHelper -@RequiresApi(Build.VERSION_CODES.R) abstract class BaseTileService : TileService() { companion object { private const val TAG = "BaseTileService" diff --git a/app/src/main/java/hk/uwu/reareye/ui/MainActivity.kt b/app/src/main/java/hk/uwu/reareye/ui/MainActivity.kt index de7b417..b9b5bf1 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/MainActivity.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/MainActivity.kt @@ -5,6 +5,9 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing @@ -34,8 +37,12 @@ import androidx.core.content.ContextCompat import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import hk.uwu.reareye.ui.components.motion.ArtVisibilityMotion +import hk.uwu.reareye.ui.components.navigation.NavigationQuickTarget import hk.uwu.reareye.ui.components.navigation.RearNavigationBar +import hk.uwu.reareye.ui.components.navigation.encodeNavigationQuickActionIds +import hk.uwu.reareye.ui.components.navigation.parseNavigationQuickActionIds import hk.uwu.reareye.ui.config.ConfigKeys +import hk.uwu.reareye.ui.config.ConfigType import hk.uwu.reareye.ui.config.ModuleNavigationBarMode import hk.uwu.reareye.ui.config.ModuleSettingsController import hk.uwu.reareye.ui.config.PrefsManager.Companion.getPrefsManager @@ -100,6 +107,17 @@ class MainActivity : ComponentActivity() { var currentScreen by remember { mutableStateOf("home") } var navBarVisible by remember { mutableStateOf(false) } var configInAppListMode by remember { mutableStateOf(false) } + var pendingConfigQuickManagerTarget by remember { + mutableStateOf(null) + } + var pendingQuickActionTransition by remember { mutableStateOf(false) } + var navigationQuickActionIds by remember { + mutableStateOf( + parseNavigationQuickActionIds( + prefsManager.getString(ConfigKeys.MODULE_NAVIGATION_QUICK_ACTIONS) + ) + ) + } LaunchedEffect(Unit) { navBarVisible = true @@ -140,6 +158,14 @@ class MainActivity : ComponentActivity() { targetState = currentScreen, contentKey = { it }, transitionSpec = { + if (pendingQuickActionTransition && targetState == "config") { + pendingQuickActionTransition = false + return@AnimatedContent ContentTransform( + targetContentEnter = EnterTransition.None, + initialContentExit = ExitTransition.None, + ) + } + val initialIndex = MainScreenOrder.indexOf(initialState).coerceAtLeast(0) val targetIndex = @@ -184,6 +210,10 @@ class MainActivity : ComponentActivity() { "config" -> ConfigScreen( bottomInnerPadding = stableBottomInset, + quickManagerTarget = pendingConfigQuickManagerTarget, + onQuickManagerTargetHandled = { + pendingConfigQuickManagerTarget = null + }, onAppListModeChange = { configInAppListMode = it }, @@ -237,7 +267,28 @@ class MainActivity : ComponentActivity() { navigationBarMode = navigationBarMode, backdrop = backdrop, shadowVisibilityProgress = navShadowProgress, + quickActionIds = navigationQuickActionIds, + onQuickActionIdsChanged = { nextIds -> + val normalizedIds = parseNavigationQuickActionIds( + encodeNavigationQuickActionIds(nextIds) + ) + navigationQuickActionIds = normalizedIds + prefsManager.putString( + ConfigKeys.MODULE_NAVIGATION_QUICK_ACTIONS, + encodeNavigationQuickActionIds(normalizedIds), + ) + }, onScreenSelected = { currentScreen = it }, + onQuickActionSelected = { target -> + when (target) { + is NavigationQuickTarget.ConfigManager -> { + pendingConfigQuickManagerTarget = target.managerType + pendingQuickActionTransition = true + configInAppListMode = true + currentScreen = "config" + } + } + }, ) } } diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/RearBadge.kt b/app/src/main/java/hk/uwu/reareye/ui/components/RearBadge.kt index e752e3d..37063ad 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/components/RearBadge.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/components/RearBadge.kt @@ -1,15 +1,19 @@ package hk.uwu.reareye.ui.components +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.luminance import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -27,6 +31,7 @@ data class RearBadgeItem( val text: String, val emphasized: Boolean = false, val palette: RearBadgePalette? = null, + val onClick: (() -> Unit)? = null, ) @Composable @@ -105,6 +110,7 @@ private fun rememberRearDefaultBadgePalette(emphasized: Boolean): RearBadgePalet fun RearBadgePill( text: String, emphasized: Boolean, + modifier: Modifier = Modifier, palette: RearBadgePalette? = null, singleLine: Boolean = true, ) { @@ -116,6 +122,7 @@ fun RearBadgePill( val resolvedPalette = palette ?: defaultPalette Surface( + modifier = modifier, color = resolvedPalette.background, shape = RoundedCornerShape(18.dp), ) { @@ -136,6 +143,7 @@ fun RearBadgePill( fun RearBadgeGroup( badges: List, modifier: Modifier = Modifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, ) { if (badges.isEmpty()) return @@ -144,9 +152,20 @@ fun RearBadgeGroup( modifier = modifier, content = { badges.forEach { badge -> + val badgeModifier = if (badge.onClick != null) { + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + role = Role.Button, + onClick = badge.onClick, + ) + } else { + Modifier + } RearBadgePill( text = badge.text, emphasized = badge.emphasized, + modifier = badgeModifier, palette = badge.palette, singleLine = true, ) @@ -163,24 +182,42 @@ fun RearBadgeGroup( val placeableIndex: Int, ) + data class BadgeLine( + val positions: List, + val width: Int, + val height: Int, + ) + val maxRowWidth = constraints.maxWidth - val positions = mutableListOf() + val lines = mutableListOf() + val currentLinePositions = mutableListOf() var currentX = 0 var currentY = 0 var currentLineHeight = 0 var maxUsedWidth = 0 + fun commitLine() { + if (currentLinePositions.isEmpty()) return + lines += BadgeLine( + positions = currentLinePositions.toList(), + width = currentX, + height = currentLineHeight, + ) + maxUsedWidth = maxOf(maxUsedWidth, currentX) + currentY += currentLineHeight + spacingPx + currentLinePositions.clear() + currentX = 0 + currentLineHeight = 0 + } + placeables.forEachIndexed { index, placeable -> val proposedX = if (currentX == 0) 0 else currentX + spacingPx if (currentX > 0 && proposedX + placeable.width > maxRowWidth) { - maxUsedWidth = maxOf(maxUsedWidth, currentX) - currentY += currentLineHeight + spacingPx - currentX = 0 - currentLineHeight = 0 + commitLine() } val placeX = if (currentX == 0) 0 else currentX + spacingPx - positions += BadgePosition( + currentLinePositions += BadgePosition( x = placeX, y = currentY, placeableIndex = index, @@ -188,15 +225,25 @@ fun RearBadgeGroup( currentX = placeX + placeable.width currentLineHeight = maxOf(currentLineHeight, placeable.height) } + commitLine() - maxUsedWidth = maxOf(maxUsedWidth, currentX) - val contentHeight = if (placeables.isEmpty()) 0 else currentY + currentLineHeight + val contentHeight = if (placeables.isEmpty()) 0 else currentY - spacingPx val layoutWidth = maxUsedWidth.coerceIn(constraints.minWidth, constraints.maxWidth) val layoutHeight = contentHeight.coerceIn(constraints.minHeight, constraints.maxHeight) layout(layoutWidth, layoutHeight) { - positions.forEach { position -> - placeables[position.placeableIndex].placeRelative(position.x, position.y) + lines.forEach { line -> + val lineOffsetX = when (horizontalAlignment) { + Alignment.CenterHorizontally -> (layoutWidth - line.width) / 2 + Alignment.End -> layoutWidth - line.width + else -> 0 + }.coerceAtLeast(0) + line.positions.forEach { position -> + placeables[position.placeableIndex].placeRelative( + position.x + lineOffsetX, + position.y + ) + } } } } diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/config/AppListSelectorScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/components/config/AppListSelectorScreen.kt index 28c8d89..661e4b4 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/components/config/AppListSelectorScreen.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/components/config/AppListSelectorScreen.kt @@ -73,11 +73,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.CircularProgressIndicator import top.yukonga.miuix.kmp.basic.FabPosition import top.yukonga.miuix.kmp.basic.FloatingActionButton import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator import top.yukonga.miuix.kmp.basic.ListPopupColumn import top.yukonga.miuix.kmp.basic.ListPopupDefaults import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior @@ -221,7 +221,7 @@ private fun LoadingAppsCard() { modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator() + InfiniteProgressIndicator() } } } diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/config/BusinessExtraConfigScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/components/config/BusinessExtraConfigScreen.kt index 626c97f..cbcfc09 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/components/config/BusinessExtraConfigScreen.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/components/config/BusinessExtraConfigScreen.kt @@ -63,9 +63,9 @@ import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Button import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.CircularProgressIndicator import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.Text @@ -293,7 +293,7 @@ fun BusinessExtraConfigManagerScreen( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator() + InfiniteProgressIndicator() Text(text = stringResource(R.string.rear_widget_loading_data)) } } diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/config/BusinessManagerScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/components/config/BusinessManagerScreen.kt index 90e53b4..2ff55f0 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/components/config/BusinessManagerScreen.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/components/config/BusinessManagerScreen.kt @@ -65,9 +65,9 @@ import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Button import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.CircularProgressIndicator import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.Text @@ -411,7 +411,7 @@ fun BusinessManagerScreen( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator() + InfiniteProgressIndicator() Text(text = stringResource(R.string.rear_widget_loading_data)) } } diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/config/CardManagerScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/components/config/CardManagerScreen.kt index 0af9477..9e469d1 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/components/config/CardManagerScreen.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/components/config/CardManagerScreen.kt @@ -72,9 +72,9 @@ import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Button import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.CircularProgressIndicator import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.Switch @@ -339,325 +339,328 @@ fun CardManagerScreen( }, ) { Scaffold( - topBar = { - TopAppBar( - modifier = Modifier.rearAcrylicEffect(hazeState, hazeStyle), - color = Color.Transparent, - title = stringResource(R.string.rear_widget_card_manager), - navigationIconPadding = 12.dp, - actionIconPadding = 12.dp, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - modifier = Modifier.graphicsLayer { - if (layoutDirection == LayoutDirection.Rtl) scaleX = -1f - }, - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } - }, - actions = { - IconButton( - onClick = { if (cardsLoaded) openCreateDialog() }) { - Icon(imageVector = Icons.Filled.Add, contentDescription = null) - } - }, - scrollBehavior = scrollBehavior, - ) - }, - ) { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .nestedScroll(scrollBehavior.nestedScrollConnection) - .scrollEndHaptic() - .overScrollVertical() - .rearAcrylicSource(hazeState) - .padding(horizontal = 12.dp), - contentPadding = PaddingValues( - top = paddingValues.calculateTopPadding() + 12.dp, - bottom = paddingValues.calculateBottomPadding() + 12.dp, - ), - verticalArrangement = Arrangement.spacedBy(8.dp), - overscrollEffect = null, - ) { - item { - Card( - modifier = Modifier - .padding(bottom = 12.dp) - .fillMaxWidth() - ) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - SuperCard( - title = stringResource(R.string.rear_widget_card_dialog_hint_title), - summary = stringResource(R.string.rear_widget_card_dialog_hint), - onClick = {}, - bottomAction = { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - if (cardsLoaded) { - RearBadgeGroup( - badges = listOf(rearWidgetCardCountBadge(cards.size)), - ) - } - Button( - onClick = { openCreateDialog() }, - colors = ButtonDefaults.buttonColorsPrimary(), - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = null, - modifier = Modifier.padding(end = 6.dp), - ) - Text(text = stringResource(R.string.rear_widget_add_card)) + topBar = { + TopAppBar( + modifier = Modifier.rearAcrylicEffect(hazeState, hazeStyle), + color = Color.Transparent, + title = stringResource(R.string.rear_widget_card_manager), + navigationIconPadding = 12.dp, + actionIconPadding = 12.dp, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + modifier = Modifier.graphicsLayer { + if (layoutDirection == LayoutDirection.Rtl) scaleX = -1f + }, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + }, + actions = { + IconButton( + onClick = { if (cardsLoaded) openCreateDialog() }) { + Icon(imageVector = Icons.Filled.Add, contentDescription = null) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + .scrollEndHaptic() + .overScrollVertical() + .rearAcrylicSource(hazeState) + .padding(horizontal = 12.dp), + contentPadding = PaddingValues( + top = paddingValues.calculateTopPadding() + 12.dp, + bottom = paddingValues.calculateBottomPadding() + 12.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + overscrollEffect = null, + ) { + item { + Card( + modifier = Modifier + .padding(bottom = 12.dp) + .fillMaxWidth() + ) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + SuperCard( + title = stringResource(R.string.rear_widget_card_dialog_hint_title), + summary = stringResource(R.string.rear_widget_card_dialog_hint), + onClick = {}, + bottomAction = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (cardsLoaded) { + RearBadgeGroup( + badges = listOf(rearWidgetCardCountBadge(cards.size)), + ) + } + Button( + onClick = { openCreateDialog() }, + colors = ButtonDefaults.buttonColorsPrimary(), + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = null, + modifier = Modifier.padding(end = 6.dp), + ) + Text(text = stringResource(R.string.rear_widget_add_card)) + } } } - } - ) + ) + } } } - } - if (!dataCardsVisible) { - item { - Card( - modifier = Modifier.fillMaxWidth(), - insideMargin = PaddingValues(vertical = 24.dp), - ) { - Box( + if (!dataCardsVisible) { + item { + Card( modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, + insideMargin = PaddingValues(vertical = 24.dp), ) { - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, ) { - CircularProgressIndicator() - Text(text = stringResource(R.string.rear_widget_loading_data)) + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + InfiniteProgressIndicator() + Text(text = stringResource(R.string.rear_widget_loading_data)) + } } } } } - } - if (dataCardsVisible) { - itemsIndexed( - items = cards, - key = { _, item -> item.id }, - contentType = { _, _ -> "card_item" }, - ) { _, item -> - val normalizedBusiness = normalizeTemplateBusinessName(item.business) - val hasTemplateConfig = - templateAvailability[item.business] == true || - templateAvailability[normalizedBusiness] == true || - item.oneConfigJson.isNullOrBlank().not() - if (prefsManager.getBoolean(ConfigKeys.MORE_DEBUG, false)) { - debugLog( - "card template action id=${item.id} business=${item.business} normalized=$normalizedBusiness available=$hasTemplateConfig rawAvailable=${templateAvailability[item.business]} normalizedAvailable=${templateAvailability[normalizedBusiness]} hasConfig=${ - item.oneConfigJson.isNullOrBlank().not() - }" - ) - } - ModuleStyleManagerCard( - title = item.title, - badges = buildList { - add(rearWidgetPackageBadge(item.packageName)) - add(rearWidgetBusinessBadge(item.business)) - add(rearWidgetPriorityBadge(item.priority)) - if (item.sticky) { - add(rearWidgetStickyBadge()) - } - if (!item.renameable) { - add(rearWidgetLockedBadge()) - } - add( - rearWidgetTemplateStatusBadge( - hasCustomConfig = item.oneConfigJson.isNullOrBlank().not(), - ) + if (dataCardsVisible) { + itemsIndexed( + items = cards, + key = { _, item -> item.id }, + contentType = { _, _ -> "card_item" }, + ) { _, item -> + val normalizedBusiness = normalizeTemplateBusinessName(item.business) + val hasTemplateConfig = + templateAvailability[item.business] == true || + templateAvailability[normalizedBusiness] == true || + item.oneConfigJson.isNullOrBlank().not() + if (prefsManager.getBoolean(ConfigKeys.MORE_DEBUG, false)) { + debugLog( + "card template action id=${item.id} business=${item.business} normalized=$normalizedBusiness available=$hasTemplateConfig rawAvailable=${templateAvailability[item.business]} normalizedAvailable=${templateAvailability[normalizedBusiness]} hasConfig=${ + item.oneConfigJson.isNullOrBlank().not() + }" ) - addAll( - rearWidgetSourceBadges( - downloadedFromStore = item.downloadedFromStore, - storeWidgetId = item.storeWidgetId, - ) - ) - }, - summaryLines = emptyList(), - trailing = { - if (item.renameable) { - Switch( - checked = item.enabled, - onCheckedChange = { checked -> - val i = cards.indexOfFirst { it.id == item.id } - if (i >= 0) { - cards[i] = cards[i].copy(enabled = checked) - scope.launch(Dispatchers.IO) { - RearWidgetManagerRepository.setCardEnabled( - context = context, - prefsManager = prefsManager, - cardId = item.id, - enabled = checked, - ) - } - } - }, - ) - } else { - Icon( - imageVector = Icons.Outlined.Lock, - contentDescription = null, - tint = MiuixTheme.colorScheme.onSurfaceVariantSummary, + } + ModuleStyleManagerCard( + title = item.title, + badges = buildList { + add(rearWidgetPackageBadge(item.packageName)) + add(rearWidgetBusinessBadge(item.business)) + add(rearWidgetPriorityBadge(item.priority)) + if (item.sticky) { + add(rearWidgetStickyBadge()) + } + if (!item.renameable) { + add(rearWidgetLockedBadge()) + } + add( + rearWidgetTemplateStatusBadge( + hasCustomConfig = item.oneConfigJson.isNullOrBlank().not(), + ) ) - } - }, - onCardClick = { openEditDialog(item) }, - leftAction = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - ModuleStyleIconAction( - icon = Icons.Rounded.EditNote, - onClick = { openEditDialog(item) }, + addAll( + rearWidgetSourceBadges( + downloadedFromStore = item.downloadedFromStore, + storeWidgetId = item.storeWidgetId, + ) ) - if (hasTemplateConfig) { + }, + summaryLines = emptyList(), + trailing = { + if (item.renameable) { + Switch( + checked = item.enabled, + onCheckedChange = { checked -> + val i = cards.indexOfFirst { it.id == item.id } + if (i >= 0) { + cards[i] = cards[i].copy(enabled = checked) + scope.launch(Dispatchers.IO) { + RearWidgetManagerRepository.setCardEnabled( + context = context, + prefsManager = prefsManager, + cardId = item.id, + enabled = checked, + ) + } + } + }, + ) + } else { + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = null, + tint = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } + }, + onCardClick = { openEditDialog(item) }, + leftAction = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ModuleStyleIconAction( + icon = Icons.Rounded.EditNote, + onClick = { openEditDialog(item) }, + ) + if (hasTemplateConfig) { + ModuleStyleDeleteAction( + icon = Icons.Filled.Tune, + text = stringResource(R.string.rear_widget_action_config), + onClick = { openTemplateConfig(item) }, + ) + } + } + }, + rightAction = { + if (item.renameable) { ModuleStyleDeleteAction( - icon = Icons.Filled.Tune, - text = stringResource(R.string.rear_widget_action_config), - onClick = { openTemplateConfig(item) }, + icon = MiuixIcons.Delete, + text = stringResource(R.string.rear_widget_action_delete), + onClick = { + if (!item.renameable) return@ModuleStyleDeleteAction + cards.remove(item) + persist() + }, ) } - } - }, - rightAction = { - if (item.renameable) { - ModuleStyleDeleteAction( - icon = MiuixIcons.Delete, - text = stringResource(R.string.rear_widget_action_delete), - onClick = { - if (!item.renameable) return@ModuleStyleDeleteAction - cards.remove(item) - persist() - }, + }, + ) + } + } + + item { + if (dataCardsVisible && cards.isEmpty()) { + ArtRevealItem(visible = true, delayMillis = 40) { + Card(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.rear_widget_empty_card), + modifier = Modifier.padding(16.dp), ) } - }, - ) + } + } } } + } - item { - if (dataCardsVisible && cards.isEmpty()) { - ArtRevealItem(visible = true, delayMillis = 40) { - Card(modifier = Modifier.fillMaxWidth()) { + OverlayDialog( + show = showDialog.value, + title = stringResource( + if (editingCardId == null) R.string.rear_widget_add_card else R.string.rear_widget_edit_card, + ), + onDismissRequest = { showDialog.value = false }, + ) { + val editingCard = editingCardId?.let { id -> cards.firstOrNull { it.id == id } } + val lockedCard = editingCard?.takeIf { !it.renameable } + DialogFormColumn { + if (lockedCard == null) { + TextField( + value = draftTitle, + onValueChange = { draftTitle = it }, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.rear_widget_card_title), + singleLine = true, + ) + TextField( + value = draftPackageName, + onValueChange = { draftPackageName = it }, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.rear_widget_target_package), + singleLine = true, + ) + TextField( + value = draftBusiness, + onValueChange = { draftBusiness = it }, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.rear_widget_business_name), + singleLine = true, + ) + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(R.string.rear_widget_card_locked_summary), + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) Text( - text = stringResource(R.string.rear_widget_empty_card), - modifier = Modifier.padding(16.dp), + text = stringResource( + R.string.rear_widget_card_summary, + lockedCard.packageName, + lockedCard.business, + lockedCard.priority, + ), + fontSize = 12.sp, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, ) } } } - } - } - } - - OverlayDialog( - show = showDialog.value, - title = stringResource( - if (editingCardId == null) R.string.rear_widget_add_card else R.string.rear_widget_edit_card, - ), - onDismissRequest = { showDialog.value = false }, - ) { - val editingCard = editingCardId?.let { id -> cards.firstOrNull { it.id == id } } - val lockedCard = editingCard?.takeIf { !it.renameable } - DialogFormColumn { - if (lockedCard == null) { - TextField( - value = draftTitle, - onValueChange = { draftTitle = it }, - modifier = Modifier.fillMaxWidth(), - label = stringResource(R.string.rear_widget_card_title), - singleLine = true, - ) TextField( - value = draftPackageName, - onValueChange = { draftPackageName = it }, + value = draftPriorityText, + onValueChange = { draftPriorityText = it }, modifier = Modifier.fillMaxWidth(), - label = stringResource(R.string.rear_widget_target_package), + label = stringResource(R.string.rear_widget_default_priority), singleLine = true, ) - TextField( - value = draftBusiness, - onValueChange = { draftBusiness = it }, + if (lockedCard == null) { + SwitchPreference( + title = stringResource(R.string.rear_widget_card_sticky), + summary = stringResource(R.string.rear_widget_card_sticky_desc), + checked = draftSticky, + onCheckedChange = { draftSticky = it }, + ) + } + Column( modifier = Modifier.fillMaxWidth(), - label = stringResource(R.string.rear_widget_business_name), - singleLine = true, - ) - } else { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), + Button( + onClick = { submitDialog() }, + colors = ButtonDefaults.buttonColorsPrimary(), + modifier = Modifier.fillMaxWidth(), ) { - Text( - text = stringResource(R.string.rear_widget_card_locked_summary), - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = stringResource( - R.string.rear_widget_card_summary, - lockedCard.packageName, - lockedCard.business, - lockedCard.priority, - ), - fontSize = 12.sp, - color = MiuixTheme.colorScheme.onSurfaceVariantSummary, - ) + Text(stringResource(R.string.rear_widget_confirm)) + } + Button( + onClick = { showDialog.value = false }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.rear_widget_cancel)) } - } - } - TextField( - value = draftPriorityText, - onValueChange = { draftPriorityText = it }, - modifier = Modifier.fillMaxWidth(), - label = stringResource(R.string.rear_widget_default_priority), - singleLine = true, - ) - if (lockedCard == null) { - SwitchPreference( - title = stringResource(R.string.rear_widget_card_sticky), - summary = stringResource(R.string.rear_widget_card_sticky_desc), - checked = draftSticky, - onCheckedChange = { draftSticky = it }, - ) - } - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button( - onClick = { submitDialog() }, - colors = ButtonDefaults.buttonColorsPrimary(), - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.rear_widget_confirm)) - } - Button(onClick = { showDialog.value = false }, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(R.string.rear_widget_cancel)) } } } - } } diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/config/CustomBoundsCompatManagerScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/components/config/CustomBoundsCompatManagerScreen.kt new file mode 100644 index 0000000..e08754c --- /dev/null +++ b/app/src/main/java/hk/uwu/reareye/ui/components/config/CustomBoundsCompatManagerScreen.kt @@ -0,0 +1,1063 @@ +package hk.uwu.reareye.ui.components.config + +import android.annotation.SuppressLint +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.rounded.EditNote +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.core.graphics.toColorInt +import com.composables.icons.materialsymbols.MaterialSymbols +import com.composables.icons.materialsymbols.rounded.Deployed_code_update +import com.composables.icons.materialsymbols.rounded.File_export +import hk.uwu.reareye.R +import hk.uwu.reareye.repository.bounds.CustomBoundsCompatAppConfig +import hk.uwu.reareye.repository.bounds.CustomBoundsCompatConfigCodec +import hk.uwu.reareye.repository.bounds.CustomBoundsFillMode +import hk.uwu.reareye.repository.bounds.CustomBoundsMode +import hk.uwu.reareye.ui.components.DialogFormColumn +import hk.uwu.reareye.ui.components.OverlayDialog +import hk.uwu.reareye.ui.components.RearBadgeItem +import hk.uwu.reareye.ui.components.card.ModuleStyleDeleteAction +import hk.uwu.reareye.ui.components.card.ModuleStyleIconAction +import hk.uwu.reareye.ui.components.card.ModuleStyleManagerCard +import hk.uwu.reareye.ui.components.card.SuperCard +import hk.uwu.reareye.ui.components.motion.ArtRevealItem +import hk.uwu.reareye.ui.components.rememberRearAccentBadgePalette +import hk.uwu.reareye.ui.config.ConfigItem +import hk.uwu.reareye.ui.config.ConfigKeys +import hk.uwu.reareye.ui.config.ConfigType +import hk.uwu.reareye.ui.config.PrefsManager +import hk.uwu.reareye.ui.theme.rearAcrylicEffect +import hk.uwu.reareye.ui.theme.rearAcrylicSource +import hk.uwu.reareye.ui.theme.rememberAcrylicHazeState +import hk.uwu.reareye.ui.theme.rememberAcrylicHazeStyle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import top.yukonga.miuix.kmp.basic.Button +import top.yukonga.miuix.kmp.basic.ButtonDefaults +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.ColorPalette +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator +import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.SpinnerEntry +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextField +import top.yukonga.miuix.kmp.basic.TopAppBar +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.extended.Delete +import top.yukonga.miuix.kmp.preference.SwitchPreference +import top.yukonga.miuix.kmp.preference.WindowSpinnerPreference +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.theme.miuixShape +import top.yukonga.miuix.kmp.utils.overScrollVertical +import top.yukonga.miuix.kmp.utils.scrollEndHaptic +import java.util.Locale + +private data class CustomBoundsOption( + val value: Int, + @param:StringRes val titleRes: Int, +) + +private val gravityOptions = listOf( + CustomBoundsOption(17, R.string.custom_bounds_position_center), + CustomBoundsOption(48, R.string.custom_bounds_position_top), + CustomBoundsOption(80, R.string.custom_bounds_position_bottom), + CustomBoundsOption(3, R.string.custom_bounds_position_left), + CustomBoundsOption(5, R.string.custom_bounds_position_right), +) + +private val rotationOptions = listOf( + CustomBoundsOption( + CustomBoundsCompatConfigCodec.ROTATION_FOLLOW_SYSTEM, + R.string.custom_bounds_rotation_follow, + ), + CustomBoundsOption(0, R.string.custom_bounds_rotation_0), + CustomBoundsOption(90, R.string.custom_bounds_rotation_90), + CustomBoundsOption(180, R.string.custom_bounds_rotation_180), + CustomBoundsOption(270, R.string.custom_bounds_rotation_270), +) + +private val modeOptions = listOf( + CustomBoundsOption(0, R.string.custom_bounds_mode_auto_ratio), + CustomBoundsOption(1, R.string.custom_bounds_mode_custom_ratio), + CustomBoundsOption(2, R.string.custom_bounds_mode_exact_insets), +) + +@SuppressLint("LocalContextGetResourceValueCall") +@Composable +fun CustomBoundsCompatManagerScreen( + prefsManager: PrefsManager, + onBack: () -> Unit, +) { + val context = LocalContext.current + val layoutDirection = LocalLayoutDirection.current + val scrollBehavior = MiuixScrollBehavior() + val hazeState = rememberAcrylicHazeState() + val hazeStyle = rememberAcrylicHazeStyle() + val scope = rememberCoroutineScope() + val configs = remember { mutableStateListOf() } + var loaded by remember { mutableStateOf(false) } + var dataCardsVisible by remember { mutableStateOf(false) } + var showAppSelector by remember { mutableStateOf(false) } + var showDialog by remember { mutableStateOf(false) } + var editingPackageName by remember { mutableStateOf(null) } + var draftEnabled by remember { mutableStateOf(true) } + var draftMode by remember { mutableIntStateOf(0) } + var draftAspectRatio by remember { mutableStateOf("") } + var draftGravity by remember { mutableIntStateOf(CustomBoundsCompatConfigCodec.DEFAULT_GRAVITY) } + var draftScale by remember { mutableStateOf("") } + var draftDensityDpi by remember { mutableStateOf("") } + var draftRotationDegrees by remember { + mutableIntStateOf(CustomBoundsCompatConfigCodec.ROTATION_FOLLOW_SYSTEM) + } + var draftInsetLeft by remember { mutableStateOf("") } + var draftInsetTop by remember { mutableStateOf("") } + var draftInsetRight by remember { mutableStateOf("") } + var draftInsetBottom by remember { mutableStateOf("") } + var draftFillEnabled by remember { mutableStateOf(false) } + var draftFillMode by remember { mutableIntStateOf(0) } + var draftFillColor by remember { mutableStateOf("#FF000000") } + var draftFillColorError by remember { mutableStateOf(false) } + var exportTarget by remember { mutableStateOf(null) } + var pendingOverwriteConfig by remember { mutableStateOf(null) } + var rejectedImportPackage by remember { mutableStateOf(null) } + var importErrorMessage by remember { mutableStateOf(null) } + + val appSelectorItem = remember { + ConfigItem( + key = ConfigKeys.CUSTOM_BOUNDS_COMPAT_APPS, + titleRes = R.string.custom_bounds_pick_apps, + descriptionRes = R.string.custom_bounds_pick_apps_desc, + type = ConfigType.AppList(defaultValues = emptySet()), + ) + } + + fun replaceConfigs(nextConfigs: List) { + configs.clear() + configs.addAll(nextConfigs.sortedBy { it.packageName.lowercase() }) + } + + fun loadConfigsFromPrefs(): List { + val selectedPackages = prefsManager.getStringSet( + ConfigKeys.CUSTOM_BOUNDS_COMPAT_APPS, + emptySet(), + ) + val storedConfigs = CustomBoundsCompatConfigCodec.parse( + prefsManager.getString( + ConfigKeys.CUSTOM_BOUNDS_COMPAT_CONFIG_DATA, + CustomBoundsCompatConfigCodec.EMPTY_ARRAY, + ) + ) + return CustomBoundsCompatConfigCodec.normalizeForPackages(storedConfigs, selectedPackages) + } + + fun persist(nextConfigs: List = configs.toList()) { + val packages = nextConfigs.mapTo(linkedSetOf()) { it.packageName } + val encoded = CustomBoundsCompatConfigCodec.encode(nextConfigs) + prefsManager.putStringSet(ConfigKeys.CUSTOM_BOUNDS_COMPAT_APPS, packages) + prefsManager.putString(ConfigKeys.CUSTOM_BOUNDS_COMPAT_CONFIG_DATA, encoded) + } + + fun toast(resId: Int) { + Toast.makeText(context, context.getString(resId), Toast.LENGTH_SHORT).show() + } + + fun applyImportedConfig(config: CustomBoundsCompatAppConfig) { + val nextConfigs = configs.toMutableList() + val index = nextConfigs.indexOfFirst { it.packageName == config.packageName } + if (index >= 0) { + nextConfigs[index] = config + } else { + nextConfigs += config + } + replaceConfigs(nextConfigs) + persist() + toast(R.string.custom_bounds_rule_import_success) + } + + fun packageInstalled(packageName: String): Boolean { + return runCatching { + context.packageManager.getPackageInfo(packageName, 0) + true + }.getOrDefault(false) + } + + fun handleImportedConfig(config: CustomBoundsCompatAppConfig) { + if (!packageInstalled(config.packageName)) { + rejectedImportPackage = config.packageName + return + } + if (configs.any { it.packageName == config.packageName }) { + pendingOverwriteConfig = config + } else { + applyImportedConfig(config) + } + } + + fun syncAfterAppSelection() { + val nextConfigs = loadConfigsFromPrefs() + replaceConfigs(nextConfigs) + persist(nextConfigs) + } + + fun openEditDialog(item: CustomBoundsCompatAppConfig) { + editingPackageName = item.packageName + draftEnabled = item.enabled + draftMode = when (item.mode) { + CustomBoundsMode.AUTO_RATIO -> 0 + CustomBoundsMode.CUSTOM_RATIO -> 1 + CustomBoundsMode.EXACT_INSETS -> 2 + } + draftAspectRatio = if (item.aspectRatio > 0f) formatFloat(item.aspectRatio) else "" + draftGravity = normalizeGravity(item.gravity) + draftScale = formatFloat(item.scale) + draftDensityDpi = item.densityDpi.takeIf { it > 0 }?.toString().orEmpty() + draftRotationDegrees = CustomBoundsCompatConfigCodec.normalizeRotation(item.rotationDegrees) + draftInsetLeft = item.insetLeft.takeIf { it > 0 }?.toString().orEmpty() + draftInsetTop = item.insetTop.takeIf { it > 0 }?.toString().orEmpty() + draftInsetRight = item.insetRight.takeIf { it > 0 }?.toString().orEmpty() + draftInsetBottom = item.insetBottom.takeIf { it > 0 }?.toString().orEmpty() + draftFillEnabled = item.fillEnabled + draftFillMode = when (item.fillMode) { + CustomBoundsFillMode.AUTO -> 0 + CustomBoundsFillMode.CUSTOM -> 1 + } + draftFillColor = formatColorInt(item.fillColorArgb.takeIf { it != 0 } ?: 0xFF000000.toInt()) + draftFillColorError = false + showDialog = true + } + + fun submitDialog() { + val packageName = editingPackageName ?: return + val mode = when (draftMode) { + 1 -> CustomBoundsMode.CUSTOM_RATIO + 2 -> CustomBoundsMode.EXACT_INSETS + else -> CustomBoundsMode.AUTO_RATIO + } + val aspectRatio = draftAspectRatio.trim().toFloatOrNull() + val scale = draftScale.trim().toFloatOrNull() + val densityDpi = draftDensityDpi.trim().ifBlank { "0" }.toIntOrNull() + val gravity = draftGravity + val rotation = draftRotationDegrees + val insetLeft = draftInsetLeft.trim().ifBlank { "0" }.toIntOrNull() + val insetTop = draftInsetTop.trim().ifBlank { "0" }.toIntOrNull() + val insetRight = draftInsetRight.trim().ifBlank { "0" }.toIntOrNull() + val insetBottom = draftInsetBottom.trim().ifBlank { "0" }.toIntOrNull() + val fillMode = when (draftFillMode) { + 1 -> CustomBoundsFillMode.CUSTOM + else -> CustomBoundsFillMode.AUTO + } + val parsedFillColor = parseColorInt(draftFillColor) + draftFillColorError = + draftFillEnabled && fillMode == CustomBoundsFillMode.CUSTOM && parsedFillColor == null + + if (scale == null || scale <= 0f || densityDpi == null || densityDpi < 0 || + gravityOptions.none { it.value == gravity } || + CustomBoundsCompatConfigCodec.normalizeRotation(rotation) != rotation || + insetLeft == null || /*insetLeft < 0 ||*/ insetTop == null || /*insetTop < 0 ||*/ + insetRight == null || /*insetRight < 0 ||*/ insetBottom == null ||/* insetBottom < 0 ||*/ + (mode == CustomBoundsMode.CUSTOM_RATIO && (aspectRatio == null || aspectRatio <= 0f)) || + (draftFillEnabled && fillMode == CustomBoundsFillMode.CUSTOM && parsedFillColor == null) + ) { + Toast.makeText( + context, + context.getString(R.string.custom_bounds_form_invalid), + Toast.LENGTH_SHORT, + ).show() + return + } + + val nextConfig = CustomBoundsCompatAppConfig( + packageName = packageName, + enabled = draftEnabled, + mode = mode, + aspectRatio = aspectRatio ?: 0f, + gravity = gravity, + scale = scale, + densityDpi = densityDpi, + rotationDegrees = rotation, + insetLeft = insetLeft, + insetTop = insetTop, + insetRight = insetRight, + insetBottom = insetBottom, + fillEnabled = draftFillEnabled, + fillMode = fillMode, + fillColorArgb = if (draftFillEnabled && fillMode == CustomBoundsFillMode.CUSTOM) { + parsedFillColor ?: 0 + } else { + 0 + }, + ) + val index = configs.indexOfFirst { it.packageName == packageName } + if (index >= 0) { + configs[index] = nextConfig + } + replaceConfigs(configs.toList()) + persist() + showDialog = false + Toast.makeText( + context, + context.getString(R.string.custom_bounds_saved), + Toast.LENGTH_SHORT, + ).show() + } + + fun exportFileName(packageName: String): String { + val safePackage = packageName.replace(Regex("[^A-Za-z0-9._-]"), "_") + return "${safePackage}_${System.currentTimeMillis()}.rbt" + } + + val exportLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/octet-stream"), + ) { uri -> + val target = exportTarget + exportTarget = null + if (uri == null || target == null) return@rememberLauncherForActivityResult + scope.launch { + val success = withContext(Dispatchers.IO) { + runCatching { + val raw = CustomBoundsCompatConfigCodec.encodeRuleFile(target) + context.contentResolver.openOutputStream(uri)?.use { output -> + output.write(raw.toByteArray(Charsets.UTF_8)) + } ?: error("openOutputStream failed") + }.isSuccess + } + if (success) { + toast(R.string.custom_bounds_rule_export_success) + } else { + toast(R.string.custom_bounds_rule_export_failed) + } + } + } + + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + val importedConfig = withContext(Dispatchers.IO) { + runCatching { + val raw = context.contentResolver.openInputStream(uri)?.use { input -> + input.readBytes().toString(Charsets.UTF_8) + } ?: error("openInputStream failed") + CustomBoundsCompatConfigCodec.decodeRuleFile(raw) + }.getOrNull() + } + if (importedConfig == null) { + importErrorMessage = context.getString(R.string.custom_bounds_rule_import_invalid) + } else { + handleImportedConfig(importedConfig) + } + } + } + + LaunchedEffect(Unit) { + delay(220) + replaceConfigs(withContext(Dispatchers.IO) { loadConfigsFromPrefs() }) + loaded = true + delay(90) + dataCardsVisible = true + } + + if (showAppSelector) { + BackHandler { showAppSelector = false } + AppListSelectorScreen( + configItem = appSelectorItem, + prefsManager = prefsManager, + onCancel = { showAppSelector = false }, + onSave = { + syncAfterAppSelection() + showAppSelector = false + }, + ) + return + } + + Scaffold( + topBar = { + TopAppBar( + modifier = Modifier.rearAcrylicEffect(hazeState, hazeStyle), + color = Color.Transparent, + title = stringResource(R.string.custom_bounds_compat_manager), + navigationIconPadding = 12.dp, + actionIconPadding = 12.dp, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + modifier = Modifier.graphicsLayer { + if (layoutDirection == LayoutDirection.Rtl) scaleX = -1f + }, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + }, + actions = { + IconButton(onClick = { + if (loaded) importLauncher.launch( + arrayOf( + "application/octet-stream", + "text/plain", + "*/*" + ) + ) + }) { + Icon( + imageVector = MaterialSymbols.Rounded.Deployed_code_update, + contentDescription = null + ) + } + IconButton(onClick = { if (loaded) showAppSelector = true }) { + Icon(imageVector = Icons.Filled.Add, contentDescription = null) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + .scrollEndHaptic() + .overScrollVertical() + .rearAcrylicSource(hazeState) + .padding(horizontal = 12.dp), + contentPadding = PaddingValues( + top = paddingValues.calculateTopPadding() + 12.dp, + bottom = paddingValues.calculateBottomPadding() + 12.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + overscrollEffect = null, + ) { + item { + Card( + modifier = Modifier + .padding(bottom = 12.dp) + .fillMaxWidth(), + ) { + SuperCard( + title = stringResource(R.string.custom_bounds_hint_title), + summary = stringResource(R.string.custom_bounds_hint), + onClick = {}, + bottomAction = { + Button( + onClick = { showAppSelector = true }, + colors = ButtonDefaults.buttonColorsPrimary(), + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = null, + modifier = Modifier.padding(end = 6.dp), + ) + Text(text = stringResource(R.string.custom_bounds_pick_apps)) + } + }, + ) + } + } + + if (!dataCardsVisible) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + insideMargin = PaddingValues(vertical = 24.dp), + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + InfiniteProgressIndicator() + Text(text = stringResource(R.string.rear_widget_loading_data)) + } + } + } + } + } + + if (dataCardsVisible) { + itemsIndexed( + items = configs, + key = { _, item -> item.packageName }, + contentType = { _, _ -> "custom_bounds_item" }, + ) { _, item -> + ModuleStyleManagerCard( + title = item.packageName, + summaryLines = emptyList(), + badges = customBoundsBadges(item), + onCardClick = { openEditDialog(item) }, + leftAction = { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ModuleStyleIconAction( + icon = Icons.Rounded.EditNote, + onClick = { openEditDialog(item) }, + ) + ModuleStyleIconAction( + icon = MaterialSymbols.Rounded.File_export, + onClick = { + exportTarget = item + exportLauncher.launch(exportFileName(item.packageName)) + }, + ) + } + }, + rightAction = { + ModuleStyleDeleteAction( + icon = MiuixIcons.Delete, + text = stringResource(R.string.rear_widget_action_delete), + onClick = { + configs.remove(item) + persist() + }, + ) + }, + ) + } + } + + item { + if (dataCardsVisible && configs.isEmpty()) { + ArtRevealItem(visible = true, delayMillis = 40) { + Card(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.custom_bounds_empty), + modifier = Modifier.padding(16.dp), + ) + } + } + } + } + } + } + + OverlayDialog( + show = showDialog, + title = stringResource(R.string.custom_bounds_edit_app), + onDismissRequest = { showDialog = false }, + ) { + DialogFormColumn { + TextField( + value = editingPackageName.orEmpty(), + onValueChange = {}, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.rear_widget_target_package), + enabled = false, + readOnly = true, + singleLine = true, + ) + SwitchPreference( + title = stringResource(R.string.custom_bounds_enable_app), + checked = draftEnabled, + onCheckedChange = { draftEnabled = it }, + ) + CustomBoundsDropdownPreference( + title = stringResource(R.string.custom_bounds_mode), + description = stringResource(R.string.custom_bounds_mode_help), + options = modeOptions, + selectedValue = draftMode, + onSelected = { draftMode = it }, + ) + if (draftMode == 1) { + TextField( + value = draftAspectRatio, + onValueChange = { draftAspectRatio = it }, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.custom_bounds_aspect_ratio), + singleLine = true, + ) + } + if (draftMode == 2) { + TextField( + value = draftInsetLeft, + onValueChange = { draftInsetLeft = it }, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.custom_bounds_inset_left), + singleLine = true, + ) + TextField( + value = draftInsetTop, + onValueChange = { draftInsetTop = it }, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.custom_bounds_inset_top), + singleLine = true, + ) + TextField( + value = draftInsetRight, + onValueChange = { draftInsetRight = it }, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.custom_bounds_inset_right), + singleLine = true, + ) + TextField( + value = draftInsetBottom, + onValueChange = { draftInsetBottom = it }, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.custom_bounds_inset_bottom), + singleLine = true, + ) + } + if (draftMode == 0) { + CustomBoundsInfoText( + text = stringResource(R.string.custom_bounds_auto_ratio_hint), + ) + } + CustomBoundsDropdownPreference( + title = stringResource(R.string.custom_bounds_gravity), + description = stringResource(R.string.custom_bounds_gravity_help), + options = gravityOptions, + selectedValue = draftGravity, + onSelected = { draftGravity = it }, + ) + TextField( + value = draftScale, + onValueChange = { draftScale = it }, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.custom_bounds_scale), + singleLine = true, + ) + TextField( + value = draftDensityDpi, + onValueChange = { draftDensityDpi = it }, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.custom_bounds_density_dpi), + singleLine = true, + ) + CustomBoundsDropdownPreference( + title = stringResource(R.string.custom_bounds_rotation_degrees), + description = stringResource(R.string.custom_bounds_rotation_help), + options = rotationOptions, + selectedValue = draftRotationDegrees, + onSelected = { draftRotationDegrees = it }, + ) + SwitchPreference( + title = stringResource(R.string.custom_bounds_fill_enable), + checked = draftFillEnabled, + onCheckedChange = { draftFillEnabled = it }, + ) + if (draftFillEnabled) { + CustomBoundsDropdownPreference( + title = stringResource(R.string.custom_bounds_fill_mode), + description = stringResource(R.string.custom_bounds_fill_mode_help), + options = listOf( + CustomBoundsOption(0, R.string.custom_bounds_fill_mode_auto), + CustomBoundsOption(1, R.string.custom_bounds_fill_mode_custom), + ), + selectedValue = draftFillMode, + onSelected = { draftFillMode = it }, + ) + if (draftFillMode == 0) { + CustomBoundsInfoText( + text = stringResource(R.string.custom_bounds_fill_auto_hint), + ) + } else { + CustomBoundsColorEditor( + value = draftFillColor, + onValueChange = { + draftFillColor = it + draftFillColorError = parseColorInt(it) == null + }, + error = draftFillColorError, + ) + } + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { submitDialog() }, + colors = ButtonDefaults.buttonColorsPrimary(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.rear_widget_confirm)) + } + Button( + onClick = { showDialog = false }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.rear_widget_cancel)) + } + } + } + } + + OverlayDialog( + show = rejectedImportPackage != null, + title = stringResource(R.string.custom_bounds_rule_import_rejected_title), + onDismissRequest = { rejectedImportPackage = null }, + ) { + DialogFormColumn { + Text( + text = stringResource( + R.string.custom_bounds_rule_import_app_missing, + rejectedImportPackage.orEmpty(), + ), + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + Button( + onClick = { rejectedImportPackage = null }, + colors = ButtonDefaults.buttonColorsPrimary(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.rear_widget_confirm)) + } + } + } + + OverlayDialog( + show = pendingOverwriteConfig != null, + title = stringResource(R.string.custom_bounds_rule_import_overwrite_title), + onDismissRequest = { pendingOverwriteConfig = null }, + ) { + DialogFormColumn { + Text( + text = stringResource( + R.string.custom_bounds_rule_import_overwrite_message, + pendingOverwriteConfig?.packageName.orEmpty(), + ), + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + Button( + onClick = { + pendingOverwriteConfig?.let(::applyImportedConfig) + pendingOverwriteConfig = null + }, + colors = ButtonDefaults.buttonColorsPrimary(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.custom_bounds_rule_import_overwrite_confirm)) + } + Button( + onClick = { pendingOverwriteConfig = null }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.rear_widget_cancel)) + } + } + } + + OverlayDialog( + show = importErrorMessage != null, + title = stringResource(R.string.custom_bounds_rule_import_failed_title), + onDismissRequest = { importErrorMessage = null }, + ) { + DialogFormColumn { + Text( + text = importErrorMessage.orEmpty(), + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + Button( + onClick = { importErrorMessage = null }, + colors = ButtonDefaults.buttonColorsPrimary(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.rear_widget_confirm)) + } + } + } +} + +private fun formatFloat(value: Float): String = + String.format(Locale.US, "%.4f", value).trimEnd('0').trimEnd('.') + +private fun normalizeGravity(gravity: Int): Int { + return gravityOptions.firstOrNull { it.value == gravity }?.value + ?: CustomBoundsCompatConfigCodec.DEFAULT_GRAVITY +} + +@Composable +private fun CustomBoundsDropdownPreference( + title: String, + description: String, + options: List, + selectedValue: Int, + onSelected: (Int) -> Unit, +) { + val optionTitles = options.map { stringResource(it.titleRes) } + val optionEntries = remember(optionTitles) { + optionTitles.map { SpinnerEntry(title = it) } + } + val selectedIndex = options.indexOfFirst { it.value == selectedValue }.coerceAtLeast(0) + WindowSpinnerPreference( + modifier = Modifier.clip(miuixShape(16.dp)), + items = optionEntries, + selectedIndex = selectedIndex, + title = title, + summary = description, + onSelectedIndexChange = { index -> onSelected(options[index].value) }, + ) +} + +@Composable +private fun CustomBoundsInfoText(text: String) { + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .clip(miuixShape(16.dp)) + .background(MiuixTheme.colorScheme.secondaryContainer.copy(alpha = 0.42f)) + .border( + width = 0.5.dp, + color = MiuixTheme.colorScheme.outline.copy(alpha = 0.18f), + shape = miuixShape(16.dp), + ) + .padding(horizontal = 16.dp, vertical = 12.dp), + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) +} + +@Composable +private fun CustomBoundsColorEditor( + value: String, + onValueChange: (String) -> Unit, + error: Boolean, +) { + val parsedColor = remember(value) { parseColorInt(value) } + val composeColor = parsedColor?.let(::Color) ?: Color.White + + fun commitColor(color: Color) { + onValueChange(formatColorInt(color.toArgb())) + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.custom_bounds_fill_palette), + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + ColorPalette( + color = composeColor, + onColorChanged = { commitColor(it) }, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = stringResource(R.string.custom_bounds_fill_color_help), + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(999.dp)) + .border( + width = 1.dp, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + shape = RoundedCornerShape(999.dp), + ) + .background(parsedColor?.let(::Color) ?: Color.Transparent), + ) + TextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.weight(1f), + label = stringResource(R.string.custom_bounds_fill_color), + singleLine = true, + ) + } + if (error) { + Text( + text = stringResource(R.string.custom_bounds_fill_color_invalid), + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +private fun parseColorInt(raw: String): Int? { + val normalized = raw.trim() + if (normalized.isBlank()) return null + return runCatching { normalized.toColorInt() }.getOrNull() +} + +private fun formatColorInt(color: Int): String = + String.format(Locale.US, "#%08X", color) + +@Composable +private fun customBoundsBadges(item: CustomBoundsCompatAppConfig): List { + val statePalette = rememberRearAccentBadgePalette( + if (item.enabled) Color(0xFF10B981) else Color(0xFF64748B) + ) + val ratioPalette = rememberRearAccentBadgePalette(Color(0xFF6366F1)) + val positionPalette = rememberRearAccentBadgePalette(Color(0xFF8B5CF6)) + val densityPalette = rememberRearAccentBadgePalette(Color(0xFF0EA5E9)) + val rotationPalette = rememberRearAccentBadgePalette(Color(0xFFF59E0B)) + val fillPalette = rememberRearAccentBadgePalette(Color(0xFFEF4444)) + return listOf( + RearBadgeItem( + text = if (item.enabled) { + stringResource(R.string.custom_bounds_enabled) + } else { + stringResource(R.string.custom_bounds_disabled) + }, + emphasized = item.enabled, + palette = statePalette, + ), + RearBadgeItem( + text = stringResource( + R.string.custom_bounds_badge_mode, + modeTitle(item.mode), + ), + palette = ratioPalette, + ), + when (item.mode) { + CustomBoundsMode.CUSTOM_RATIO -> RearBadgeItem( + text = stringResource( + R.string.custom_bounds_badge_ratio, + formatFloat(item.aspectRatio), + ), + palette = ratioPalette, + ) + + CustomBoundsMode.EXACT_INSETS -> RearBadgeItem( + text = stringResource( + R.string.custom_bounds_badge_insets, + "${item.insetLeft},${item.insetTop},${item.insetRight},${item.insetBottom}", + ), + palette = ratioPalette, + ) + + else -> RearBadgeItem( + text = stringResource(R.string.custom_bounds_auto_ratio_hint), + palette = ratioPalette, + ) + }, + RearBadgeItem( + text = stringResource( + R.string.custom_bounds_badge_scale, + formatFloat(item.scale), + ), + palette = ratioPalette, + ), + RearBadgeItem( + text = stringResource( + R.string.custom_bounds_badge_position, + gravityTitle(item.gravity), + ), + palette = positionPalette, + ), + RearBadgeItem( + text = stringResource( + R.string.custom_bounds_badge_density, + item.densityDpi.takeIf { it > 0 }?.toString() + ?: stringResource(R.string.custom_bounds_follow_system), + ), + palette = densityPalette, + ), + RearBadgeItem( + text = stringResource( + R.string.custom_bounds_badge_rotation, + formatRotation(item.rotationDegrees), + ), + palette = rotationPalette, + ), + RearBadgeItem( + text = if (!item.fillEnabled) { + stringResource(R.string.custom_bounds_fill_disabled) + } else if (item.fillMode == CustomBoundsFillMode.AUTO) { + stringResource(R.string.custom_bounds_fill_mode_auto) + } else { + stringResource( + R.string.custom_bounds_fill_custom_badge, + formatColorInt(item.fillColorArgb), + ) + }, + emphasized = item.fillEnabled, + palette = fillPalette, + ), + ) +} + +@Composable +private fun modeTitle(mode: CustomBoundsMode): String { + return when (mode) { + CustomBoundsMode.AUTO_RATIO -> stringResource(R.string.custom_bounds_mode_auto_ratio) + CustomBoundsMode.CUSTOM_RATIO -> stringResource(R.string.custom_bounds_mode_custom_ratio) + CustomBoundsMode.EXACT_INSETS -> stringResource(R.string.custom_bounds_mode_exact_insets) + } +} + +@Composable +private fun gravityTitle(gravity: Int): String { + val option = gravityOptions.firstOrNull { it.value == gravity } + return if (option != null) { + stringResource(option.titleRes) + } else { + stringResource(R.string.custom_bounds_position_custom, gravity) + } +} + +@Composable +private fun formatRotation(rotationDegrees: Int): String { + return if (rotationDegrees == CustomBoundsCompatConfigCodec.ROTATION_FOLLOW_SYSTEM) { + stringResource(R.string.custom_bounds_follow_system) + } else { + stringResource(R.string.custom_bounds_rotation_value, rotationDegrees) + } +} diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/config/RearWallpaperManagementContent.kt b/app/src/main/java/hk/uwu/reareye/ui/components/config/RearWallpaperManagementContent.kt index e8bf09f..687cef6 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/components/config/RearWallpaperManagementContent.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/components/config/RearWallpaperManagementContent.kt @@ -62,8 +62,8 @@ import top.yukonga.miuix.kmp.basic.BasicComponentDefaults import top.yukonga.miuix.kmp.basic.Button import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.CircularProgressIndicator import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.basic.Switch import top.yukonga.miuix.kmp.basic.Text @@ -492,7 +492,7 @@ private fun RearWallpaperManagementList( Card(modifier = Modifier.fillMaxWidth()) { SuperCard( title = stringResource(R.string.rear_wallpaper_loading), - startAction = { CircularProgressIndicator() }, + startAction = { InfiniteProgressIndicator() }, ) } } diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/config/RearWallpaperManagerScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/components/config/RearWallpaperManagerScreen.kt index ca53be0..b49f920 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/components/config/RearWallpaperManagerScreen.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/components/config/RearWallpaperManagerScreen.kt @@ -112,9 +112,9 @@ import top.yukonga.miuix.kmp.basic.BasicComponentDefaults import top.yukonga.miuix.kmp.basic.Button import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.CircularProgressIndicator import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.Switch @@ -488,546 +488,553 @@ fun RearWallpaperManagerScreen( }, ) { Scaffold( - topBar = { - TopAppBar( - modifier = Modifier.rearAcrylicEffect(hazeState, hazeStyle), - color = Color.Transparent, - title = stringResource( - if (activePage == RearWallpaperPage.MANAGEMENT) { - R.string.rear_wallpaper_manage_title - } else { - R.string.rear_wallpaper_manager - } - ), - navigationIconPadding = 12.dp, - actionIconPadding = 12.dp, - navigationIcon = { - IconButton( - onClick = { - if (activePage == RearWallpaperPage.MANAGEMENT) { - activePage = RearWallpaperPage.ROTATION - } else { - onBack() - } - }, - ) { - Icon( - modifier = Modifier.graphicsLayer { - if (layoutDirection == LayoutDirection.Rtl) scaleX = -1f - }, - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } - }, - actions = { - if (activePage == RearWallpaperPage.ROTATION) { + topBar = { + TopAppBar( + modifier = Modifier.rearAcrylicEffect(hazeState, hazeStyle), + color = Color.Transparent, + title = stringResource( + if (activePage == RearWallpaperPage.MANAGEMENT) { + R.string.rear_wallpaper_manage_title + } else { + R.string.rear_wallpaper_manager + } + ), + navigationIconPadding = 12.dp, + actionIconPadding = 12.dp, + navigationIcon = { IconButton( - onClick = { clearAllRotatingWallpapers() }, + onClick = { + if (activePage == RearWallpaperPage.MANAGEMENT) { + activePage = RearWallpaperPage.ROTATION + } else { + onBack() + } + }, ) { Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = stringResource(R.string.rear_wallpaper_clear_schedule), + modifier = Modifier.graphicsLayer { + if (layoutDirection == LayoutDirection.Rtl) scaleX = -1f + }, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, ) } - } - IconButton( - onClick = { if (!refreshing) refreshCatalog(showSuccessToast = true) }, - ) { - Icon(imageVector = Icons.Rounded.Refresh, contentDescription = null) - } - }, - scrollBehavior = scrollBehavior, - ) - }, - ) { paddingValues -> - AnimatedContent( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { clip = true }, - targetState = activePage, - contentKey = { it }, - transitionSpec = { - val forward = targetState == RearWallpaperPage.MANAGEMENT - - fadeIn( - animationSpec = tween( - durationMillis = 210, - delayMillis = 50, - easing = LinearOutSlowInEasing, - ) - ) + slideInHorizontally( - animationSpec = tween( - durationMillis = 280, - easing = FastOutSlowInEasing, - ) - ) { fullWidth -> - if (forward) fullWidth / 9 else -fullWidth / 9 - } togetherWith ( - fadeOut( - animationSpec = tween( - durationMillis = 110, - easing = FastOutLinearInEasing, - ) - ) + slideOutHorizontally( - animationSpec = tween( - durationMillis = 190, - easing = FastOutLinearInEasing, - ) - ) { fullWidth -> - if (forward) -fullWidth / 12 else fullWidth / 12 + }, + actions = { + if (activePage == RearWallpaperPage.ROTATION) { + IconButton( + onClick = { clearAllRotatingWallpapers() }, + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.rear_wallpaper_clear_schedule), + ) + } } - ) + IconButton( + onClick = { if (!refreshing) refreshCatalog(showSuccessToast = true) }, + ) { + Icon(imageVector = Icons.Rounded.Refresh, contentDescription = null) + } + }, + scrollBehavior = scrollBehavior, + ) }, - label = "RearWallpaperPageTransition", - ) { currentPage -> - when (currentPage) { - RearWallpaperPage.MANAGEMENT -> { - RearWallpaperManagementContent( - paddingValues = paddingValues, - scrollBehavior = scrollBehavior, - hazeState = hazeState, - wallpapers = wallpapers, - storeWallpaperSources = storeWallpaperSources, - currentWallpaperId = currentWallpaperId, - loading = loading, - refreshing = refreshing, - onRefresh = { if (!refreshing) refreshCatalog(showSuccessToast = true) }, - onSetCurrent = ::switchWallpaper, - onImport = ::importWallpaperPackage, - onUpdateMetadata = ::updateWallpaperMetadata, - onEditTemplate = { activeTemplateWallpaperId = it.wallpaperId }, - onGeneratePreview = ::generateWallpaperPreview, - onDelete = ::deleteWallpaper, - ) - } - - RearWallpaperPage.ROTATION -> { - Box( - modifier = Modifier - .fillMaxSize() - .onGloballyPositioned { coordinates -> - contentTopInRoot = coordinates.positionInRoot().y + ) { paddingValues -> + AnimatedContent( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { clip = true }, + targetState = activePage, + contentKey = { it }, + transitionSpec = { + val forward = targetState == RearWallpaperPage.MANAGEMENT + + fadeIn( + animationSpec = tween( + durationMillis = 210, + delayMillis = 50, + easing = LinearOutSlowInEasing, + ) + ) + slideInHorizontally( + animationSpec = tween( + durationMillis = 280, + easing = FastOutSlowInEasing, + ) + ) { fullWidth -> + if (forward) fullWidth / 9 else -fullWidth / 9 + } togetherWith ( + fadeOut( + animationSpec = tween( + durationMillis = 110, + easing = FastOutLinearInEasing, + ) + ) + slideOutHorizontally( + animationSpec = tween( + durationMillis = 190, + easing = FastOutLinearInEasing, + ) + ) { fullWidth -> + if (forward) -fullWidth / 12 else fullWidth / 12 } - ) { - LazyColumn( + ) + }, + label = "RearWallpaperPageTransition", + ) { currentPage -> + when (currentPage) { + RearWallpaperPage.MANAGEMENT -> { + RearWallpaperManagementContent( + paddingValues = paddingValues, + scrollBehavior = scrollBehavior, + hazeState = hazeState, + wallpapers = wallpapers, + storeWallpaperSources = storeWallpaperSources, + currentWallpaperId = currentWallpaperId, + loading = loading, + refreshing = refreshing, + onRefresh = { if (!refreshing) refreshCatalog(showSuccessToast = true) }, + onSetCurrent = ::switchWallpaper, + onImport = ::importWallpaperPackage, + onUpdateMetadata = ::updateWallpaperMetadata, + onEditTemplate = { activeTemplateWallpaperId = it.wallpaperId }, + onGeneratePreview = ::generateWallpaperPreview, + onDelete = ::deleteWallpaper, + ) + } + + RearWallpaperPage.ROTATION -> { + Box( modifier = Modifier .fillMaxSize() - .nestedScroll(scrollBehavior.nestedScrollConnection) - .scrollEndHaptic() - .overScrollVertical() - .rearAcrylicSource(hazeState) - .padding(horizontal = 12.dp), - state = listState, - contentPadding = PaddingValues( - top = paddingValues.calculateTopPadding() + 12.dp, - bottom = paddingValues.calculateBottomPadding() + 12.dp, - ), - verticalArrangement = Arrangement.spacedBy(8.dp), - overscrollEffect = null, - userScrollEnabled = draggedId == null, + .onGloballyPositioned { coordinates -> + contentTopInRoot = coordinates.positionInRoot().y + } ) { - item { - Card(modifier = Modifier.fillMaxWidth()) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - SuperCard( - title = stringResource(R.string.rear_wallpaper_status_title), - onClick = {}, - bottomAction = { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - RearBadgeGroup(badges = statusBadges) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy( - 8.dp - ), + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + .scrollEndHaptic() + .overScrollVertical() + .rearAcrylicSource(hazeState) + .padding(horizontal = 12.dp), + state = listState, + contentPadding = PaddingValues( + top = paddingValues.calculateTopPadding() + 12.dp, + bottom = paddingValues.calculateBottomPadding() + 12.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + overscrollEffect = null, + userScrollEnabled = draggedId == null, + ) { + item { + Card(modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + SuperCard( + title = stringResource(R.string.rear_wallpaper_status_title), + onClick = {}, + bottomAction = { + Column( + verticalArrangement = Arrangement.spacedBy( + 10.dp + ) ) { - Button( - onClick = { - pickerMode.value = - WallpaperPickerMode.ADD_TO_SCHEDULE - }, - colors = ButtonDefaults.buttonColorsPrimary(), - modifier = Modifier.weight(1f), - ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = null, - modifier = Modifier.padding(end = 6.dp), - ) - Text(stringResource(R.string.rear_wallpaper_add_sheet_trigger)) - } - Button( - onClick = { - activePage = - RearWallpaperPage.MANAGEMENT - }, - modifier = Modifier.weight(1f), + RearBadgeGroup(badges = statusBadges) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + 8.dp + ), ) { - Text(stringResource(R.string.rear_wallpaper_manage_title)) + Button( + onClick = { + pickerMode.value = + WallpaperPickerMode.ADD_TO_SCHEDULE + }, + colors = ButtonDefaults.buttonColorsPrimary(), + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = null, + modifier = Modifier.padding(end = 6.dp), + ) + Text(stringResource(R.string.rear_wallpaper_add_sheet_trigger)) + } + Button( + onClick = { + activePage = + RearWallpaperPage.MANAGEMENT + }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.rear_wallpaper_manage_title)) + } } } - } - }, - ) - } - } - } - - item { - Card(modifier = Modifier.fillMaxWidth()) { - BasicComponent( - title = stringResource(R.string.rear_wallpaper_schedule_feature_title), - summary = stringResource(R.string.rear_wallpaper_schedule_hint), - summaryColor = BasicComponentDefaults.summaryColor(), - onClick = {}, - endActions = { - Switch( - checked = scheduleEnabled, - onCheckedChange = { checked -> - if (checked && schedule.isEmpty()) { - toast(R.string.rear_wallpaper_schedule_empty) - } else { - scheduleEnabled = checked - persistSchedule() - } }, ) - }, - ) + } + } } - } - item { - Card(modifier = Modifier.fillMaxWidth()) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + item { + Card(modifier = Modifier.fillMaxWidth()) { BasicComponent( - title = stringResource(R.string.rear_wallpaper_schedule_title), - summary = if (schedule.isEmpty()) { - stringResource(R.string.rear_wallpaper_schedule_empty) - } else { - stringResource(R.string.rear_wallpaper_schedule_order_hint) - }, + title = stringResource(R.string.rear_wallpaper_schedule_feature_title), + summary = stringResource(R.string.rear_wallpaper_schedule_hint), summaryColor = BasicComponentDefaults.summaryColor(), onClick = {}, + endActions = { + Switch( + checked = scheduleEnabled, + onCheckedChange = { checked -> + if (checked && schedule.isEmpty()) { + toast(R.string.rear_wallpaper_schedule_empty) + } else { + scheduleEnabled = checked + persistSchedule() + } + }, + ) + }, ) } } - } - if (schedule.isNotEmpty()) { - items(renderedSchedule, key = { it.wallpaperId }) { entry -> - val wallpaper = wallpaperMap[entry.wallpaperId] - val isDragged = draggedId == entry.wallpaperId - val isSettling = settlingWallpaperId == entry.wallpaperId - Box( - modifier = Modifier - .fillMaxWidth() - .then( - if (isDragged || isSettling) { - Modifier + item { + Card(modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + BasicComponent( + title = stringResource(R.string.rear_wallpaper_schedule_title), + summary = if (schedule.isEmpty()) { + stringResource(R.string.rear_wallpaper_schedule_empty) } else { - Modifier.animateItem( - placementSpec = tween( - durationMillis = SETTLING_OVERLAY_DURATION_MS.toInt(), - easing = FastOutSlowInEasing, - ) - ) - } + stringResource(R.string.rear_wallpaper_schedule_order_hint) + }, + summaryColor = BasicComponentDefaults.summaryColor(), + onClick = {}, ) - .zIndex(if (isDragged) 1f else 0f) - .onGloballyPositioned { coordinates -> - scheduleItemBounds[entry.wallpaperId] = ItemBounds( - top = coordinates.positionInRoot().y, - height = coordinates.size.height.toFloat(), + } + } + } + + if (schedule.isNotEmpty()) { + items(renderedSchedule, key = { it.wallpaperId }) { entry -> + val wallpaper = wallpaperMap[entry.wallpaperId] + val isDragged = draggedId == entry.wallpaperId + val isSettling = settlingWallpaperId == entry.wallpaperId + Box( + modifier = Modifier + .fillMaxWidth() + .then( + if (isDragged || isSettling) { + Modifier + } else { + Modifier.animateItem( + placementSpec = tween( + durationMillis = SETTLING_OVERLAY_DURATION_MS.toInt(), + easing = FastOutSlowInEasing, + ) + ) + } ) - } - .pointerInput(entry.wallpaperId) { - detectDragGesturesAfterLongPress( - onDragStart = { - settlingOverlay = null - settlingWallpaperId = null - draggedId = entry.wallpaperId - val bounds = - scheduleItemBounds[entry.wallpaperId] - draggedInsertIndex = - schedule.indexOfFirst { it.wallpaperId == entry.wallpaperId } - draggedStartTop = bounds?.top ?: 0f - draggedItemHeight = bounds?.height ?: 0f - draggedOffsetY = 0f - }, - onDragCancel = { - draggedId = null - draggedInsertIndex = null - draggedItemHeight = 0f - draggedOffsetY = 0f - }, - onDragEnd = { - val draggingId = draggedId - val finalSchedule = previewScheduleEntries( - schedule = schedule, - draggingId = draggingId, - insertIndex = draggedInsertIndex, + .zIndex(if (isDragged) 1f else 0f) + .onGloballyPositioned { coordinates -> + scheduleItemBounds[entry.wallpaperId] = + ItemBounds( + top = coordinates.positionInRoot().y, + height = coordinates.size.height.toFloat(), ) - val draggedEntrySnapshot = - draggingId?.let { wallpaperId -> - schedule.firstOrNull { it.wallpaperId == wallpaperId } - } - val startTranslationY = - draggedStartTop - contentTopInRoot + draggedOffsetY - if (draggedEntrySnapshot != null) { - settlingOverlay = - SettlingScheduleOverlay( - entry = draggedEntrySnapshot, - isCurrent = currentWallpaperId == draggedEntrySnapshot.wallpaperId, - startTranslationY = startTranslationY, - targetTranslationY = startTranslationY, + } + .pointerInput(entry.wallpaperId) { + detectDragGesturesAfterLongPress( + onDragStart = { + settlingOverlay = null + settlingWallpaperId = null + draggedId = entry.wallpaperId + val bounds = + scheduleItemBounds[entry.wallpaperId] + draggedInsertIndex = + schedule.indexOfFirst { it.wallpaperId == entry.wallpaperId } + draggedStartTop = bounds?.top ?: 0f + draggedItemHeight = bounds?.height ?: 0f + draggedOffsetY = 0f + }, + onDragCancel = { + draggedId = null + draggedInsertIndex = null + draggedItemHeight = 0f + draggedOffsetY = 0f + }, + onDragEnd = { + val draggingId = draggedId + val finalSchedule = + previewScheduleEntries( + schedule = schedule, + draggingId = draggingId, + insertIndex = draggedInsertIndex, ) - settlingWallpaperId = - draggedEntrySnapshot.wallpaperId - } - if (finalSchedule.map { it.wallpaperId } != schedule.map { it.wallpaperId }) { - schedule.clear() - schedule.addAll(finalSchedule) - persistSchedule() - } - draggedId = null - draggedInsertIndex = null - draggedItemHeight = 0f - draggedOffsetY = 0f - - if (draggingId != null) { - scope.launch { - withFrameNanos { } - withFrameNanos { } - val resolvedTarget = - scheduleItemBounds[draggingId]?.top?.minus( - contentTopInRoot + val draggedEntrySnapshot = + draggingId?.let { wallpaperId -> + schedule.firstOrNull { it.wallpaperId == wallpaperId } + } + val startTranslationY = + draggedStartTop - contentTopInRoot + draggedOffsetY + if (draggedEntrySnapshot != null) { + settlingOverlay = + SettlingScheduleOverlay( + entry = draggedEntrySnapshot, + isCurrent = currentWallpaperId == draggedEntrySnapshot.wallpaperId, + startTranslationY = startTranslationY, + targetTranslationY = startTranslationY, ) - if (resolvedTarget != null && settlingOverlay?.entry?.wallpaperId == draggingId) { - settlingOverlay = - settlingOverlay?.copy( - targetTranslationY = resolvedTarget + settlingWallpaperId = + draggedEntrySnapshot.wallpaperId + } + if (finalSchedule.map { it.wallpaperId } != schedule.map { it.wallpaperId }) { + schedule.clear() + schedule.addAll(finalSchedule) + persistSchedule() + } + draggedId = null + draggedInsertIndex = null + draggedItemHeight = 0f + draggedOffsetY = 0f + + if (draggingId != null) { + scope.launch { + withFrameNanos { } + withFrameNanos { } + val resolvedTarget = + scheduleItemBounds[draggingId]?.top?.minus( + contentTopInRoot ) + if (resolvedTarget != null && settlingOverlay?.entry?.wallpaperId == draggingId) { + settlingOverlay = + settlingOverlay?.copy( + targetTranslationY = resolvedTarget + ) + } } } - } - }, - onDrag = { change, dragAmount -> - change.consume() - val draggingId = - draggedId - ?: return@detectDragGesturesAfterLongPress - val currentHeight = - scheduleItemBounds[draggingId]?.height - ?: draggedItemHeight - draggedOffsetY += dragAmount.y - val draggedCenter = - draggedStartTop + draggedOffsetY + currentHeight / 2f - draggedInsertIndex = findDraggedInsertIndex( - schedule = schedule, - bounds = scheduleItemBounds, - draggedCenter = draggedCenter, - ) - }, - ) - } - ) { - ScheduleItemCard( - modifier = Modifier.fillMaxWidth(), - wallpaper = wallpaper, - scheduleEntry = entry, - isCurrent = currentWallpaperId == entry.wallpaperId, - isDragged = isDragged, - isDragPlaceholder = isDragged || isSettling, - dragOffsetY = 0f, - onEdit = { - editTargetId.value = entry.wallpaperId - delayInput = entry.delayMs.toString() - }, - onDelete = { - schedule.removeAll { it.wallpaperId == entry.wallpaperId } - persistSchedule() - }, - ) + }, + onDrag = { change, dragAmount -> + change.consume() + val draggingId = + draggedId + ?: return@detectDragGesturesAfterLongPress + val currentHeight = + scheduleItemBounds[draggingId]?.height + ?: draggedItemHeight + draggedOffsetY += dragAmount.y + val draggedCenter = + draggedStartTop + draggedOffsetY + currentHeight / 2f + draggedInsertIndex = + findDraggedInsertIndex( + schedule = schedule, + bounds = scheduleItemBounds, + draggedCenter = draggedCenter, + ) + }, + ) + } + ) { + ScheduleItemCard( + modifier = Modifier.fillMaxWidth(), + wallpaper = wallpaper, + scheduleEntry = entry, + isCurrent = currentWallpaperId == entry.wallpaperId, + isDragged = isDragged, + isDragPlaceholder = isDragged || isSettling, + dragOffsetY = 0f, + onEdit = { + editTargetId.value = entry.wallpaperId + delayInput = entry.delayMs.toString() + }, + onDelete = { + schedule.removeAll { it.wallpaperId == entry.wallpaperId } + persistSchedule() + }, + ) + } } } - } - if (loading) { - item { - Card(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator() - Spacer(Modifier.width(10.dp)) - Text(text = stringResource(R.string.rear_wallpaper_loading)) + if (loading) { + item { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + InfiniteProgressIndicator() + Spacer(Modifier.width(10.dp)) + Text(text = stringResource(R.string.rear_wallpaper_loading)) + } } } } - } - if (!loading && wallpapers.isEmpty()) { - item { - Card(modifier = Modifier.fillMaxWidth()) { - SuperCard( - title = stringResource(R.string.rear_wallpaper_catalog_empty) - ) + if (!loading && wallpapers.isEmpty()) { + item { + Card(modifier = Modifier.fillMaxWidth()) { + SuperCard( + title = stringResource(R.string.rear_wallpaper_catalog_empty) + ) + } } } } - } - draggedEntry?.let { entry -> - val draggedOverlayStyleState = - remember(entry.wallpaperId) { MutableStyleState(null) } - val draggedOverlayStyle = - remember(draggedStartTop, contentTopInRoot, draggedOffsetY) { - Style { - translationY(draggedStartTop - contentTopInRoot + draggedOffsetY) + draggedEntry?.let { entry -> + val draggedOverlayStyleState = + remember(entry.wallpaperId) { MutableStyleState(null) } + val draggedOverlayStyle = + remember(draggedStartTop, contentTopInRoot, draggedOffsetY) { + Style { + translationY(draggedStartTop - contentTopInRoot + draggedOffsetY) + } } - } - ScheduleItemCard( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp) - .styleable(draggedOverlayStyleState, draggedOverlayStyle) - .zIndex(3f), - wallpaper = wallpaperMap[entry.wallpaperId], - scheduleEntry = entry, - isCurrent = currentWallpaperId == entry.wallpaperId, - isDragged = true, - isDragPlaceholder = false, - dragOffsetY = 0f, - externalShadow = 18.dp, - onEdit = { - editTargetId.value = entry.wallpaperId - delayInput = entry.delayMs.toString() - }, - onDelete = { - schedule.removeAll { it.wallpaperId == entry.wallpaperId } - draggedId = null - draggedInsertIndex = null - draggedItemHeight = 0f - draggedOffsetY = 0f - persistSchedule() - }, - ) - } ?: settlingOverlay?.let { overlay -> - SettlingScheduleItemOverlay( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp) - .zIndex(3f), - wallpaper = wallpaperMap[overlay.entry.wallpaperId], - scheduleEntry = overlay.entry, - isCurrent = overlay.isCurrent, - startTranslationY = overlay.startTranslationY, - targetTranslationY = overlay.targetTranslationY, - onEdit = { - editTargetId.value = overlay.entry.wallpaperId - delayInput = overlay.entry.delayMs.toString() - }, - onDelete = { - schedule.removeAll { it.wallpaperId == overlay.entry.wallpaperId } - settlingOverlay = null - settlingWallpaperId = null - persistSchedule() - }, - ) + ScheduleItemCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .styleable(draggedOverlayStyleState, draggedOverlayStyle) + .zIndex(3f), + wallpaper = wallpaperMap[entry.wallpaperId], + scheduleEntry = entry, + isCurrent = currentWallpaperId == entry.wallpaperId, + isDragged = true, + isDragPlaceholder = false, + dragOffsetY = 0f, + externalShadow = 18.dp, + onEdit = { + editTargetId.value = entry.wallpaperId + delayInput = entry.delayMs.toString() + }, + onDelete = { + schedule.removeAll { it.wallpaperId == entry.wallpaperId } + draggedId = null + draggedInsertIndex = null + draggedItemHeight = 0f + draggedOffsetY = 0f + persistSchedule() + }, + ) + } ?: settlingOverlay?.let { overlay -> + SettlingScheduleItemOverlay( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .zIndex(3f), + wallpaper = wallpaperMap[overlay.entry.wallpaperId], + scheduleEntry = overlay.entry, + isCurrent = overlay.isCurrent, + startTranslationY = overlay.startTranslationY, + targetTranslationY = overlay.targetTranslationY, + onEdit = { + editTargetId.value = overlay.entry.wallpaperId + delayInput = overlay.entry.delayMs.toString() + }, + onDelete = { + schedule.removeAll { it.wallpaperId == overlay.entry.wallpaperId } + settlingOverlay = null + settlingWallpaperId = null + persistSchedule() + }, + ) + } } } } } } - } - OverlayBottomSheet( - show = pickerMode.value != null, - title = stringResource( - when (pickerMode.value) { - WallpaperPickerMode.ADD_TO_SCHEDULE -> R.string.rear_wallpaper_picker_add - null -> R.string.rear_wallpaper_manager - } - ), - onDismissRequest = { pickerMode.value = null }, - ) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 560.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - contentPadding = PaddingValues(top = 4.dp, bottom = 34.dp), + OverlayBottomSheet( + show = pickerMode.value != null, + title = stringResource( + when (pickerMode.value) { + WallpaperPickerMode.ADD_TO_SCHEDULE -> R.string.rear_wallpaper_picker_add + null -> R.string.rear_wallpaper_manager + } + ), + onDismissRequest = { pickerMode.value = null }, ) { - if (loading) { - item { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator() - Spacer(Modifier.width(10.dp)) - Text(text = stringResource(R.string.rear_wallpaper_loading)) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 560.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(top = 4.dp, bottom = 34.dp), + ) { + if (loading) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + InfiniteProgressIndicator() + Spacer(Modifier.width(10.dp)) + Text(text = stringResource(R.string.rear_wallpaper_loading)) + } } } - } - items(wallpapers, key = { it.wallpaperId }) { wallpaper -> - WallpaperPickerCard( - wallpaper = wallpaper, - isCurrent = wallpaper.wallpaperId == currentWallpaperId, - inSchedule = schedule.any { it.wallpaperId == wallpaper.wallpaperId }, - onAddToSchedule = { addToSchedule(wallpaper.wallpaperId) }, - ) + items(wallpapers, key = { it.wallpaperId }) { wallpaper -> + WallpaperPickerCard( + wallpaper = wallpaper, + isCurrent = wallpaper.wallpaperId == currentWallpaperId, + inSchedule = schedule.any { it.wallpaperId == wallpaper.wallpaperId }, + onAddToSchedule = { addToSchedule(wallpaper.wallpaperId) }, + ) + } } } - } - OverlayDialog( - show = editTargetId.value != null, - title = stringResource(R.string.rear_wallpaper_edit_interval), - onDismissRequest = { editTargetId.value = null }, - ) { - DialogFormColumn { - TextField( - value = delayInput, - onValueChange = { delayInput = it.filter(Char::isDigit) }, - modifier = Modifier.fillMaxWidth(), - label = stringResource(R.string.rear_wallpaper_interval_millis), - singleLine = true, - ) - Button( - onClick = { - val targetId = editTargetId.value ?: return@Button - val delayMs = delayInput.toLongOrNull() - if (delayMs == null || delayMs < RearWallpaperScheduleCodec.MIN_DELAY_MS) { - toast(R.string.rear_wallpaper_interval_invalid) - return@Button - } - val index = schedule.indexOfFirst { it.wallpaperId == targetId } - if (index >= 0) { - schedule[index] = schedule[index].copy(delayMs = delayMs) - persistSchedule() - } - editTargetId.value = null - }, - colors = ButtonDefaults.buttonColorsPrimary(), - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.rear_widget_confirm)) - } - Button( - onClick = { editTargetId.value = null }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.rear_widget_cancel)) + OverlayDialog( + show = editTargetId.value != null, + title = stringResource(R.string.rear_wallpaper_edit_interval), + onDismissRequest = { editTargetId.value = null }, + ) { + DialogFormColumn { + TextField( + value = delayInput, + onValueChange = { delayInput = it.filter(Char::isDigit) }, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.rear_wallpaper_interval_millis), + singleLine = true, + ) + Button( + onClick = { + val targetId = editTargetId.value ?: return@Button + val delayMs = delayInput.toLongOrNull() + if (delayMs == null || delayMs < RearWallpaperScheduleCodec.MIN_DELAY_MS) { + toast(R.string.rear_wallpaper_interval_invalid) + return@Button + } + val index = schedule.indexOfFirst { it.wallpaperId == targetId } + if (index >= 0) { + schedule[index] = schedule[index].copy(delayMs = delayMs) + persistSchedule() + } + editTargetId.value = null + }, + colors = ButtonDefaults.buttonColorsPrimary(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.rear_widget_confirm)) + } + Button( + onClick = { editTargetId.value = null }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.rear_widget_cancel)) + } } } - } } } diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/config/SceneRouteManagerScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/components/config/SceneRouteManagerScreen.kt index 0d8893d..11a6648 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/components/config/SceneRouteManagerScreen.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/components/config/SceneRouteManagerScreen.kt @@ -58,9 +58,9 @@ import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Button import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.CircularProgressIndicator import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.Text @@ -279,7 +279,7 @@ fun SceneRouteManagerScreen( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator() + InfiniteProgressIndicator() Text(text = stringResource(R.string.rear_widget_loading_data)) } } diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/config/template/TemplateVarConfigScreenScaffold.kt b/app/src/main/java/hk/uwu/reareye/ui/components/config/template/TemplateVarConfigScreenScaffold.kt index af8cc59..79aba59 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/components/config/template/TemplateVarConfigScreenScaffold.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/components/config/template/TemplateVarConfigScreenScaffold.kt @@ -27,9 +27,9 @@ import hk.uwu.reareye.ui.theme.rememberAcrylicHazeState import hk.uwu.reareye.ui.theme.rememberAcrylicHazeStyle import top.yukonga.miuix.kmp.basic.Button import top.yukonga.miuix.kmp.basic.ButtonDefaults -import top.yukonga.miuix.kmp.basic.CircularProgressIndicator import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.Text @@ -92,7 +92,7 @@ fun TemplateVarConfigScreenScaffold( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator() + InfiniteProgressIndicator() Text(text = loadingText) } } diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/navigation/FloatingBottomBar.kt b/app/src/main/java/hk/uwu/reareye/ui/components/navigation/FloatingBottomBar.kt index 3bc838d..19da4cd 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/components/navigation/FloatingBottomBar.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/components/navigation/FloatingBottomBar.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.EaseOut import androidx.compose.animation.core.spring import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -64,23 +65,20 @@ import kotlin.math.abs import kotlin.math.sign val LocalFloatingBottomBarTabScale = staticCompositionLocalOf { { 1f } } +val LocalFloatingBottomBarDuplicateLayer = staticCompositionLocalOf { false } @Composable fun RowScope.FloatingBottomBarItem( onClick: () -> Unit, modifier: Modifier = Modifier, + quickGestureModifier: Modifier = Modifier, + popup: @Composable (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit, ) { val scale = LocalFloatingBottomBarTabScale.current - Column( + val isDuplicateLayer = LocalFloatingBottomBarDuplicateLayer.current + Box( modifier - .clip(ContinuousCapsule) - .clickable( - interactionSource = null, - indication = null, - role = Role.Tab, - onClick = onClick, - ) .fillMaxHeight() .weight(1f) .graphicsLayer { @@ -88,10 +86,28 @@ fun RowScope.FloatingBottomBarItem( scaleX = scaleValue scaleY = scaleValue }, - verticalArrangement = Arrangement.spacedBy(1.dp, Alignment.CenterVertically), - horizontalAlignment = Alignment.CenterHorizontally, - content = content, - ) + contentAlignment = Alignment.Center, + ) { + Column( + Modifier + .matchParentSize() + .clip(ContinuousCapsule) + .then(quickGestureModifier) + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + role = Role.Tab, + onClick = onClick, + onLongClick = null, + ), + verticalArrangement = Arrangement.spacedBy(1.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + content = content, + ) + if (!isDuplicateLayer) { + popup?.invoke() + } + } } @Composable @@ -103,6 +119,9 @@ fun FloatingBottomBar( tabsCount: Int, isBlurEnabled: Boolean = true, shadowVisibilityProgress: Float = 1f, + selectedOverlayModifier: Modifier = Modifier, + renderDuplicateContentLayer: Boolean = true, + quickGestureModifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit, ) { val isInLightTheme = MiuixTheme.colorScheme.surface.luminance() >= 0.5f @@ -215,7 +234,9 @@ fun FloatingBottomBar( null } Box( - modifier = modifier.width(IntrinsicSize.Min), + modifier = modifier + .width(IntrinsicSize.Min) + .then(quickGestureModifier), contentAlignment = Alignment.CenterStart, ) { Row( @@ -267,11 +288,12 @@ fun FloatingBottomBar( content = content, ) - if (isBlurEnabled) { + if (isBlurEnabled && renderDuplicateContentLayer) { CompositionLocalProvider( LocalFloatingBottomBarTabScale provides { lerp(1f, 1.2f, dampedDragAnimation.pressProgress) - } + }, + LocalFloatingBottomBarDuplicateLayer provides true, ) { Row( Modifier @@ -319,6 +341,7 @@ fun FloatingBottomBar( } } .then(interactiveHighlight?.gestureModifier ?: Modifier) + .then(selectedOverlayModifier) .then(dampedDragAnimation.modifier) .drawBackdrop( backdrop = if (isBlurEnabled) { diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/navigation/NavigationQuickActionConfig.kt b/app/src/main/java/hk/uwu/reareye/ui/components/navigation/NavigationQuickActionConfig.kt new file mode 100644 index 0000000..85a0295 --- /dev/null +++ b/app/src/main/java/hk/uwu/reareye/ui/components/navigation/NavigationQuickActionConfig.kt @@ -0,0 +1,47 @@ +package hk.uwu.reareye.ui.components.navigation + +const val MaxNavigationQuickActions = 5 + +const val NavigationQuickActionComponentManagerId = "config_manager_business" +const val NavigationQuickActionCardManagerId = "config_manager_card" +const val NavigationQuickActionWallpaperManagerId = "config_manager_wallpaper" +const val NavigationQuickActionSceneRouteManagerId = "config_manager_scene_route" +const val NavigationQuickActionBusinessExtraManagerId = "config_manager_business_extra" +const val NavigationQuickActionBoundsManagerId = "config_manager_bounds" +const val NavigationQuickActionLyricsManagerId = "config_manager_lyrics" + +val DefaultNavigationQuickActionIds = listOf( + NavigationQuickActionComponentManagerId, + NavigationQuickActionCardManagerId, +) + +val AvailableNavigationQuickActionIds = listOf( + NavigationQuickActionComponentManagerId, + NavigationQuickActionCardManagerId, + NavigationQuickActionWallpaperManagerId, + NavigationQuickActionSceneRouteManagerId, + NavigationQuickActionBusinessExtraManagerId, + NavigationQuickActionBoundsManagerId, + NavigationQuickActionLyricsManagerId, +) + +fun parseNavigationQuickActionIds(value: String): List { + val availableLookup = AvailableNavigationQuickActionIds.toSet() + val parsed = value + .split('|') + .map { it.trim() } + .filter { it.isNotEmpty() && it in availableLookup } + .distinct() + .take(MaxNavigationQuickActions) + + return parsed.ifEmpty { DefaultNavigationQuickActionIds } +} + +fun encodeNavigationQuickActionIds(ids: List): String { + val availableLookup = AvailableNavigationQuickActionIds.toSet() + return ids + .filter { it in availableLookup } + .distinct() + .take(MaxNavigationQuickActions) + .joinToString("|") +} \ No newline at end of file diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/navigation/RearNavigationBar.kt b/app/src/main/java/hk/uwu/reareye/ui/components/navigation/RearNavigationBar.kt index adf4de7..282c28e 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/components/navigation/RearNavigationBar.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/components/navigation/RearNavigationBar.kt @@ -1,48 +1,163 @@ package hk.uwu.reareye.ui.components.navigation -import android.os.Build +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.BorderStyle import androidx.compose.material.icons.rounded.CloudDownload import androidx.compose.material.icons.rounded.Cottage +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.LibraryMusic +import androidx.compose.material.icons.rounded.Menu +import androidx.compose.material.icons.rounded.Route import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Wallpaper +import androidx.compose.material.icons.rounded.Widgets import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import androidx.compose.ui.zIndex +import com.composables.icons.materialsymbols.MaterialSymbols +import com.composables.icons.materialsymbols.rounded.Stacks import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.shadow.InnerShadow +import com.kyant.backdrop.shadow.Shadow +import com.kyant.capsule.ContinuousCapsule import hk.uwu.reareye.R +import hk.uwu.reareye.ui.config.ConfigType import hk.uwu.reareye.ui.config.ModuleNavigationBarMode +import hk.uwu.reareye.utils.BlurredBar +import hk.uwu.reareye.utils.blend.rememberBlurBackdrop +import top.yukonga.miuix.kmp.basic.Button +import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.NavigationBar import top.yukonga.miuix.kmp.basic.NavigationBarDisplayMode -import top.yukonga.miuix.kmp.basic.NavigationBarItem import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextButton +import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet import top.yukonga.miuix.kmp.theme.MiuixTheme +sealed interface NavigationQuickTarget { + @Immutable + data class ConfigManager(val managerType: ConfigType.ManagerType) : NavigationQuickTarget +} + +@Immutable +private data class NavigationQuickAction( + val id: String, + val label: String, + val icon: ImageVector, + val target: NavigationQuickTarget, +) + @Immutable private data class NavigationDestination( val route: String, val label: String, val icon: ImageVector, + val quickActions: List = emptyList(), +) + +@Immutable +private data class NavigationQuickActionStyle( + val containerColor: Color, + val contentColor: Color, + val borderColor: Color, + val hoveredContainerColor: Color, + val selectedContainerColor: Color, + val selectedBorderColor: Color, + val shadowAlpha: Float, + val shadowElevation: Dp, + val usesGlass: Boolean, ) private const val HOME_ROUTE = "home" private const val STORE_ROUTE = "store" private const val CONFIG_ROUTE = "config" private const val ABOUT_ROUTE = "about" +private const val EDIT_QUICK_ACTIONS_KEY = "__edit_quick_actions__" +private const val QUICK_ACTION_BUTTON_WIDTH_DP = 176 +private const val QUICK_ACTION_HALF_LONG_PRESS_MIN_MS = 180L @Composable fun RearNavigationBar( @@ -51,13 +166,32 @@ fun RearNavigationBar( navigationBarMode: ModuleNavigationBarMode, backdrop: Backdrop, shadowVisibilityProgress: Float = 1f, + quickActionIds: List = DefaultNavigationQuickActionIds, + onQuickActionIdsChanged: (List) -> Unit = {}, onScreenSelected: (String) -> Unit, + onQuickActionSelected: (NavigationQuickTarget) -> Unit = {}, ) { val homeLabel = stringResource(R.string.home_navigation) val storeLabel = stringResource(R.string.store_navigation) val configLabel = stringResource(R.string.configuration_navigation) val aboutLabel = stringResource(R.string.about_navigation) - val items = remember(homeLabel, storeLabel, configLabel, aboutLabel) { + val editLabel = stringResource(R.string.navigation_quick_edit) + val allQuickActions = rememberNavigationQuickActions() + val quickActionLookup = remember(allQuickActions) { allQuickActions.associateBy { it.id } } + val enabledQuickActions = remember(quickActionIds, allQuickActions) { + quickActionIds + .mapNotNull { quickActionLookup[it] } + .distinctBy { it.id } + .take(MaxNavigationQuickActions) + } + + val items = remember( + homeLabel, + storeLabel, + configLabel, + aboutLabel, + enabledQuickActions, + ) { listOf( NavigationDestination( route = HOME_ROUTE, @@ -73,6 +207,7 @@ fun RearNavigationBar( route = CONFIG_ROUTE, label = configLabel, icon = Icons.Rounded.Settings, + quickActions = enabledQuickActions, ), NavigationDestination( route = ABOUT_ROUTE, @@ -81,28 +216,107 @@ fun RearNavigationBar( ), ) } + val quickMenuController = remember { NavigationQuickMenuController() } + var expandedQuickActionsRoute by remember { mutableStateOf(null) } + var showQuickActionEditor by remember { mutableStateOf(false) } - if (navigationBarMode == ModuleNavigationBarMode.NORMAL) { - NavigationBar( - modifier = modifier, - color = MiuixTheme.colorScheme.surface, - mode = NavigationBarDisplayMode.IconAndText, - ) { - items.forEach { item -> - NavigationBarItem( - selected = currentScreen == item.route, - onClick = { onScreenSelected(item.route) }, - icon = item.icon, - label = item.label, - ) + fun openQuickActions( + item: NavigationDestination, + anchorCenterX: Float, + anchorTop: Float, + density: Float + ) { + if (item.quickActions.isNotEmpty()) { + quickMenuController.begin(anchorCenterX, anchorTop, density) + expandedQuickActionsRoute = item.route + } + } + + fun closeQuickActions() { + expandedQuickActionsRoute = null + quickMenuController.reset() + } + + fun openEditor() { + closeQuickActions() + showQuickActionEditor = true + } + + fun selectQuickAction(target: NavigationQuickTarget) { + quickMenuController.reset() + onQuickActionSelected(target) + expandedQuickActionsRoute = null + } + + NavigationQuickActionEditor( + show = showQuickActionEditor, + selectedIds = quickActionIds, + allActions = allQuickActions, + onDismissRequest = { showQuickActionEditor = false }, + onSave = { nextIds -> + onQuickActionIdsChanged(nextIds) + showQuickActionEditor = false + }, + ) + + if (navigationBarMode == ModuleNavigationBarMode.NORMAL || navigationBarMode == ModuleNavigationBarMode.SEMI_TRANSPARENT) { + val bar: @Composable () -> Unit = { + NavigationBar( + modifier = modifier, + color = if (navigationBarMode == ModuleNavigationBarMode.NORMAL) { + MiuixTheme.colorScheme.surface + } else { + Color.Transparent + }, + mode = NavigationBarDisplayMode.IconAndText, + ) { + items.forEach { item -> + RearNavigationBarItem( + item = item, + selected = currentScreen == item.route, + expanded = expandedQuickActionsRoute == item.route, + navigationBarMode = navigationBarMode, + editLabel = editLabel, + quickMenuController = quickMenuController, + onClick = { + closeQuickActions() + onScreenSelected(item.route) + }, + onOpenQuickActions = { anchorCenterX, anchorTop, density -> + openQuickActions(item, anchorCenterX, anchorTop, density) + }, + onDismissQuickActions = ::closeQuickActions, + onOpenEditor = ::openEditor, + onQuickActionSelected = ::selectQuickAction, + ) + } } } + if (navigationBarMode == ModuleNavigationBarMode.SEMI_TRANSPARENT) { + val enable = rememberBlurBackdrop(true) + BlurredBar(backdrop = enable, bar) + } else { + bar() + } return } - val enableGlass = navigationBarMode == ModuleNavigationBarMode.FLOATING_GLASS && - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + val enableGlass = navigationBarMode == ModuleNavigationBarMode.FLOATING_GLASS val selectedIndex = items.indexOfFirst { it.route == currentScreen }.coerceAtLeast(0) + val selectedItem = items.getOrNull(selectedIndex) + val selectedOverlayQuickGesture = selectedItem?.let { item -> + Modifier.navigationQuickActionDragGesture( + enabled = item.quickActions.isNotEmpty(), + actions = item.quickActions, + quickMenuController = quickMenuController, + onOpenQuickActions = { anchorCenterX, anchorTop, density -> + openQuickActions(item, anchorCenterX, anchorTop, density) + }, + onDismissQuickActions = ::closeQuickActions, + onOpenEditor = ::openEditor, + onQuickActionSelected = ::selectQuickAction, + ) + } ?: Modifier FloatingBottomBar( modifier = modifier @@ -116,15 +330,47 @@ fun RearNavigationBar( .calculateBottomPadding() ), selectedIndex = selectedIndex, - onSelected = { onScreenSelected(items[it].route) }, + onSelected = { + closeQuickActions() + onScreenSelected(items[it].route) + }, backdrop = backdrop, tabsCount = items.size, isBlurEnabled = enableGlass, shadowVisibilityProgress = shadowVisibilityProgress, + selectedOverlayModifier = Modifier, + renderDuplicateContentLayer = true, + quickGestureModifier = selectedOverlayQuickGesture, ) { items.forEachIndexed { index, item -> FloatingBottomBarItem( - onClick = { onScreenSelected(items[index].route) }, + onClick = { + closeQuickActions() + onScreenSelected(items[index].route) + }, + quickGestureModifier = Modifier.navigationQuickActionDragGesture( + enabled = item.quickActions.isNotEmpty(), + actions = item.quickActions, + quickMenuController = quickMenuController, + onOpenQuickActions = { anchorCenterX, anchorTop, density -> + openQuickActions(item, anchorCenterX, anchorTop, density) + }, + onDismissQuickActions = ::closeQuickActions, + onOpenEditor = ::openEditor, + onQuickActionSelected = ::selectQuickAction, + ), + popup = { + NavigationQuickActionsPopup( + expanded = expandedQuickActionsRoute == item.route, + actions = item.quickActions, + navigationBarMode = navigationBarMode, + editLabel = editLabel, + quickMenuController = quickMenuController, + onDismissRequest = ::closeQuickActions, + onOpenEditor = ::openEditor, + onActionSelected = ::selectQuickAction, + ) + }, modifier = Modifier.defaultMinSize(minWidth = 76.dp), ) { Icon( @@ -145,3 +391,1255 @@ fun RearNavigationBar( } } } + +@Composable +private fun rememberNavigationQuickActions(): List { + val componentManagerLabel = stringResource(R.string.navigation_quick_component_manager) + val cardManagerLabel = stringResource(R.string.navigation_quick_card_manager) + val wallpaperManagerLabel = stringResource(R.string.rear_wallpaper_manager) + val sceneRouteManagerLabel = stringResource(R.string.rear_widget_scene_route_manager) + val businessExtraManagerLabel = stringResource(R.string.rear_widget_business_extra_manager) + val boundsManagerLabel = stringResource(R.string.custom_bounds_compat_manager) + val lyricsManagerLabel = stringResource(R.string.navigation_quick_lyrics_manager) + + return remember( + componentManagerLabel, + cardManagerLabel, + wallpaperManagerLabel, + sceneRouteManagerLabel, + businessExtraManagerLabel, + boundsManagerLabel, + lyricsManagerLabel, + ) { + listOf( + NavigationQuickAction( + id = NavigationQuickActionComponentManagerId, + label = componentManagerLabel, + icon = Icons.Rounded.Widgets, + target = NavigationQuickTarget.ConfigManager(ConfigType.ManagerType.BUSINESS), + ), + NavigationQuickAction( + id = NavigationQuickActionCardManagerId, + label = cardManagerLabel, + icon = MaterialSymbols.Rounded.Stacks, + target = NavigationQuickTarget.ConfigManager(ConfigType.ManagerType.CARD), + ), + NavigationQuickAction( + id = NavigationQuickActionWallpaperManagerId, + label = wallpaperManagerLabel, + icon = Icons.Rounded.Wallpaper, + target = NavigationQuickTarget.ConfigManager(ConfigType.ManagerType.REAR_WALLPAPER), + ), + NavigationQuickAction( + id = NavigationQuickActionSceneRouteManagerId, + label = sceneRouteManagerLabel, + icon = Icons.Rounded.Route, + target = NavigationQuickTarget.ConfigManager(ConfigType.ManagerType.SCENE_ROUTE), + ), + NavigationQuickAction( + id = NavigationQuickActionBusinessExtraManagerId, + label = businessExtraManagerLabel, + icon = Icons.Rounded.Extension, + target = NavigationQuickTarget.ConfigManager(ConfigType.ManagerType.BUSINESS_EXTRA), + ), + NavigationQuickAction( + id = NavigationQuickActionBoundsManagerId, + label = boundsManagerLabel, + icon = Icons.Rounded.BorderStyle, + target = NavigationQuickTarget.ConfigManager(ConfigType.ManagerType.BOUNDS), + ), + NavigationQuickAction( + id = NavigationQuickActionLyricsManagerId, + label = lyricsManagerLabel, + icon = Icons.Rounded.LibraryMusic, + target = NavigationQuickTarget.ConfigManager(ConfigType.ManagerType.LYRICS), + ), + ) + } +} + +@Composable +private fun RowScope.RearNavigationBarItem( + item: NavigationDestination, + selected: Boolean, + expanded: Boolean, + navigationBarMode: ModuleNavigationBarMode, + editLabel: String, + quickMenuController: NavigationQuickMenuController, + onClick: () -> Unit, + onOpenQuickActions: (anchorCenterX: Float, anchorTop: Float, density: Float) -> Unit, + onDismissQuickActions: () -> Unit, + onOpenEditor: () -> Unit, + onQuickActionSelected: (NavigationQuickTarget) -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val onSurfaceContainerColor = MiuixTheme.colorScheme.onSurfaceContainer + val tint = when { + isPressed -> if (selected) { + onSurfaceContainerColor.copy(alpha = 0.5f) + } else { + onSurfaceContainerColor.copy(alpha = 0.6f) + } + + selected -> onSurfaceContainerColor + else -> onSurfaceContainerColor.copy(alpha = 0.4f) + } + val fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal + + Box( + modifier = Modifier + .height(64.dp) + .weight(1f), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .navigationQuickActionDragGesture( + enabled = item.quickActions.isNotEmpty(), + actions = item.quickActions, + quickMenuController = quickMenuController, + onOpenQuickActions = onOpenQuickActions, + onDismissQuickActions = onDismissQuickActions, + onOpenEditor = onOpenEditor, + onQuickActionSelected = onQuickActionSelected, + ) + .combinedClickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + onLongClick = null, + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + Icon( + imageVector = item.icon, + contentDescription = item.label, + tint = tint, + modifier = Modifier + .padding(top = 8.dp) + .size(26.dp), + ) + Text( + modifier = Modifier.padding(bottom = 8.dp), + text = item.label, + color = tint, + fontSize = 12.sp, + fontWeight = fontWeight, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) + } + + NavigationQuickActionsPopup( + expanded = expanded, + actions = item.quickActions, + navigationBarMode = navigationBarMode, + editLabel = editLabel, + quickMenuController = quickMenuController, + onDismissRequest = onDismissQuickActions, + onOpenEditor = onOpenEditor, + onActionSelected = onQuickActionSelected, + ) + } +} + +@Composable +private fun NavigationQuickActionsPopup( + expanded: Boolean, + actions: List, + navigationBarMode: ModuleNavigationBarMode, + editLabel: String, + quickMenuController: NavigationQuickMenuController, + onDismissRequest: () -> Unit, + onOpenEditor: () -> Unit, + onActionSelected: (NavigationQuickTarget) -> Unit, +) { + val density = LocalDensity.current + val menuBackdrop = rememberLayerBackdrop() + val positionProvider = remember(density) { + NavigationQuickActionsPopupPositionProvider( + spacingPx = with(density) { 10.dp.roundToPx() }, + windowPaddingPx = with(density) { 12.dp.roundToPx() }, + ) + } + val progress = remember { Animatable(0f) } + var popupVisible by remember { mutableStateOf(false) } + + LaunchedEffect(expanded, actions) { + if (expanded && actions.isNotEmpty()) { + popupVisible = true + progress.animateTo( + targetValue = 1f, + animationSpec = tween(220, easing = FastOutSlowInEasing), + ) + } else if (popupVisible) { + progress.animateTo( + targetValue = 0f, + animationSpec = tween(170, easing = FastOutSlowInEasing), + ) + popupVisible = false + } + } + + if ((!expanded && !popupVisible) || actions.isEmpty()) return + + Popup( + popupPositionProvider = positionProvider, + onDismissRequest = onDismissRequest, + properties = PopupProperties( + focusable = false, + dismissOnBackPress = true, + dismissOnClickOutside = true, + ), + ) { + val progressValue = progress.value + Box( + modifier = Modifier + .padding(2.dp) + .graphicsLayer { + alpha = progressValue + val scale = 0.92f + 0.08f * progressValue + scaleX = scale + scaleY = scale + translationY = with(density) { (1f - progressValue) * 8.dp.toPx() } + }, + contentAlignment = Alignment.TopCenter, + ) { + val buttonWidth = QUICK_ACTION_BUTTON_WIDTH_DP.dp + val showEditTarget = + quickMenuController.showEditTarget || quickMenuController.hoveredKey == EDIT_QUICK_ACTIONS_KEY + val editTargetProgress by animateFloatAsState( + targetValue = if (showEditTarget) 1f else 0f, + animationSpec = tween( + if (showEditTarget) 150 else 180, + easing = FastOutSlowInEasing + ), + label = "QuickActionEditTargetProgress", + ) + + NavigationQuickActionsBackdropLayer( + actionsCount = actions.size, + editTargetProgress = editTargetProgress, + width = buttonWidth, + modifier = Modifier.layerBackdrop(menuBackdrop), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .width(buttonWidth) + .height(40.dp), + contentAlignment = Alignment.Center, + ) { + if (editTargetProgress > 0.01f) { + NavigationQuickActionButton( + label = editLabel, + icon = Icons.Rounded.Edit, + navigationBarMode = navigationBarMode, + backdrop = menuBackdrop, + width = buttonWidth, + hovered = quickMenuController.hoveredKey == EDIT_QUICK_ACTIONS_KEY, + isEditTarget = true, + modifier = Modifier.graphicsLayer { + alpha = editTargetProgress + val appearScale = 0.94f + 0.06f * editTargetProgress + scaleX = appearScale + scaleY = appearScale + }, + onClick = onOpenEditor, + onPositioned = { _ -> }, + ) + } + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + actions.forEachIndexed { index, action -> + NavigationQuickActionButton( + label = action.label, + icon = action.icon, + navigationBarMode = navigationBarMode, + backdrop = menuBackdrop, + width = buttonWidth, + hovered = quickMenuController.hoveredKey == action.id, + delayProgress = ((progressValue - index * 0.05f).coerceIn(0f, 1f)), + onClick = { onActionSelected(action.target) }, + onPositioned = { _ -> }, + ) + } + } + } + } + } +} + +@Composable +private fun NavigationQuickActionsBackdropLayer( + actionsCount: Int, + editTargetProgress: Float, + width: Dp, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .width(width) + .height(40.dp) + .graphicsLayer { alpha = editTargetProgress } + .background( + MiuixTheme.colorScheme.surfaceContainer.copy(alpha = 0.72f), + ContinuousCapsule + ), + ) + repeat(actionsCount) { + Box( + modifier = Modifier + .width(width) + .height(40.dp) + .background( + MiuixTheme.colorScheme.surfaceContainer.copy(alpha = 0.66f), + ContinuousCapsule + ), + ) + } + } +} + +@Composable +private fun NavigationQuickActionButton( + label: String, + icon: ImageVector, + navigationBarMode: ModuleNavigationBarMode, + backdrop: Backdrop, + width: Dp, + hovered: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + delayProgress: Float = 1f, + isEditTarget: Boolean = false, + onPositioned: (LayoutCoordinates) -> Unit = {}, +) { + val density = LocalDensity.current + val style = rememberNavigationQuickActionStyle(navigationBarMode) + val shouldUseGlass = style.usesGlass + val targetContainerColor = when { + hovered && shouldUseGlass -> style.selectedContainerColor + hovered -> style.hoveredContainerColor + else -> style.containerColor + } + val containerColor by animateColorAsState( + targetValue = targetContainerColor, + animationSpec = tween(160, easing = FastOutSlowInEasing), + label = "QuickActionContainerColor", + ) + val borderColor by animateColorAsState( + targetValue = if (hovered && shouldUseGlass) style.selectedBorderColor else style.borderColor, + animationSpec = tween(160, easing = FastOutSlowInEasing), + label = "QuickActionBorderColor", + ) + val targetScale = when { + hovered && isEditTarget -> 1.12f + hovered -> 1.08f + isEditTarget -> 1.04f + else -> 1f + } + val animatedScale by animateFloatAsState( + targetValue = targetScale, + animationSpec = spring(dampingRatio = 0.72f, stiffness = 420f), + label = "QuickActionScale", + ) + val progressScale = 0.96f + 0.04f * delayProgress + + val baseModifier = modifier + .onGloballyPositioned(onPositioned) + .width(width) + .height(40.dp) + .graphicsLayer { + shape = ContinuousCapsule + clip = true + scaleX = animatedScale * progressScale + scaleY = animatedScale * progressScale + shadowElevation = + with(density) { (style.shadowElevation + if (hovered) 8.dp else 0.dp).toPx() } + ambientShadowColor = Color.Black.copy(alpha = style.shadowAlpha) + spotShadowColor = Color.Black.copy(alpha = style.shadowAlpha) + } + .then( + if (shouldUseGlass) { + Modifier.drawBackdrop( + backdrop = backdrop, + shape = { ContinuousCapsule }, + effects = { + vibrancy() + blur(if (hovered) 10f.dp.toPx() else 8f.dp.toPx()) + lens( + if (hovered) 34f.dp.toPx() else 24f.dp.toPx(), + if (hovered) 38f.dp.toPx() else 26f.dp.toPx(), + ) + }, + highlight = { + Highlight.Default.copy(alpha = if (hovered) 1f else 0.86f) + }, + shadow = { + Shadow.Default.copy( + color = Color.Black.copy(alpha = 0.18f), + alpha = if (hovered) 0.58f else 0.32f, + ) + }, + innerShadow = { + InnerShadow( + radius = if (hovered) 12.dp else 7.dp, + alpha = if (hovered) 0.42f else 0.22f, + ) + }, + onDrawSurface = { + drawRect(containerColor) + drawRect(Color.White.copy(alpha = if (hovered) 0.16f else 0.055f)) + drawRect(Color.Black.copy(alpha = if (hovered) 0.025f else 0.012f)) + }, + ) + } else { + Modifier.background(containerColor, ContinuousCapsule) + } + ) + .border(0.75.dp, borderColor, ContinuousCapsule) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + + Box( + modifier = baseModifier, + contentAlignment = Alignment.Center, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = style.contentColor, + modifier = Modifier.size(19.dp), + ) + AutoShrinkText( + text = label, + color = style.contentColor, + maxFontSize = 12.sp, + minFontSize = 9.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun rememberNavigationQuickActionStyle( + navigationBarMode: ModuleNavigationBarMode, +): NavigationQuickActionStyle { + val isLight = MiuixTheme.colorScheme.surface.luminance() >= 0.5f + val outline = MiuixTheme.colorScheme.dividerLine.copy(alpha = if (isLight) 0.7f else 0.86f) + val primaryTint = MiuixTheme.colorScheme.primary.copy(alpha = if (isLight) 0.14f else 0.2f) + return when (navigationBarMode) { + ModuleNavigationBarMode.NORMAL -> NavigationQuickActionStyle( + containerColor = MiuixTheme.colorScheme.surface, + contentColor = MiuixTheme.colorScheme.onSurface, + borderColor = outline, + hoveredContainerColor = blendColors(MiuixTheme.colorScheme.surface, primaryTint), + selectedContainerColor = blendColors(MiuixTheme.colorScheme.surface, primaryTint), + selectedBorderColor = outline, + shadowAlpha = if (isLight) 0.13f else 0.28f, + shadowElevation = 10.dp, + usesGlass = false, + ) + + ModuleNavigationBarMode.SEMI_TRANSPARENT -> NavigationQuickActionStyle( + containerColor = MiuixTheme.colorScheme.surface.copy(alpha = 0.9f), + contentColor = MiuixTheme.colorScheme.onSurface, + borderColor = outline.copy(alpha = 0.9f), + hoveredContainerColor = blendColors( + MiuixTheme.colorScheme.surface.copy(alpha = 0.94f), + primaryTint + ), + selectedContainerColor = blendColors( + MiuixTheme.colorScheme.surface.copy(alpha = 0.94f), + primaryTint + ), + selectedBorderColor = outline.copy(alpha = 0.9f), + shadowAlpha = if (isLight) 0.16f else 0.3f, + shadowElevation = 12.dp, + usesGlass = false, + ) + + ModuleNavigationBarMode.FLOATING -> NavigationQuickActionStyle( + containerColor = MiuixTheme.colorScheme.surfaceContainer, + contentColor = MiuixTheme.colorScheme.onSurfaceContainer, + borderColor = outline, + hoveredContainerColor = blendColors( + MiuixTheme.colorScheme.surfaceContainer, + primaryTint + ), + selectedContainerColor = blendColors( + MiuixTheme.colorScheme.surfaceContainer, + primaryTint + ), + selectedBorderColor = outline, + shadowAlpha = if (isLight) 0.18f else 0.34f, + shadowElevation = 14.dp, + usesGlass = false, + ) + + ModuleNavigationBarMode.FLOATING_GLASS -> NavigationQuickActionStyle( + containerColor = if (isLight) { + Color.White.copy(alpha = 0.115f) + } else { + Color.White.copy(alpha = 0.065f) + }, + contentColor = MiuixTheme.colorScheme.onSurface, + borderColor = Color.White.copy(alpha = if (isLight) 0.34f else 0.15f), + hoveredContainerColor = if (isLight) { + Color.White.copy(alpha = 0.18f) + } else { + Color.White.copy(alpha = 0.105f) + }, + selectedContainerColor = if (isLight) { + Color.White.copy(alpha = 0.245f) + } else { + Color.White.copy(alpha = 0.145f) + }, + selectedBorderColor = Color.White.copy(alpha = if (isLight) 0.58f else 0.28f), + shadowAlpha = if (isLight) 0.14f else 0.3f, + shadowElevation = 16.dp, + usesGlass = true, + ) + } +} + +private fun blendColors(base: Color, overlay: Color): Color { + val alpha = overlay.alpha.coerceIn(0f, 1f) + return Color( + red = base.red * (1f - alpha) + overlay.red * alpha, + green = base.green * (1f - alpha) + overlay.green * alpha, + blue = base.blue * (1f - alpha) + overlay.blue * alpha, + alpha = base.alpha, + ) +} + +@Composable +private fun AutoShrinkText( + text: String, + color: Color, + maxFontSize: TextUnit, + minFontSize: TextUnit, + modifier: Modifier = Modifier, + fontWeight: FontWeight? = null, + textAlign: TextAlign? = null, +) { + var fontSize by remember(text, maxFontSize) { mutableStateOf(maxFontSize) } + Text( + text = text, + modifier = modifier, + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + textAlign = textAlign, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + onTextLayout = { layoutResult -> + if (layoutResult.hasVisualOverflow && fontSize.value > minFontSize.value) { + fontSize = (fontSize.value - 0.5f).coerceAtLeast(minFontSize.value).sp + } + }, + ) +} + +private class NavigationQuickMenuController { + private var densityScaleFallback = 1f + + var hoveredKey by mutableStateOf(null) + private set + + var showEditTarget by mutableStateOf(false) + private set + + private var anchorCenterX = 0f + private var anchorTop = 0f + + fun begin(anchorCenterX: Float, anchorTop: Float, density: Float) { + densityScaleFallback = density + this.anchorCenterX = anchorCenterX + this.anchorTop = anchorTop + hoveredKey = null + showEditTarget = false + } + + fun reset() { + hoveredKey = null + showEditTarget = false + anchorCenterX = 0f + anchorTop = 0f + } + + fun updateHover(pointer: Offset, actions: List): String? { + val buttonWidth = QUICK_ACTION_BUTTON_WIDTH_DP * densityScaleFallback + val buttonHeight = 40f * densityScaleFallback + val gap = 8f * densityScaleFallback + val menuBottom = anchorTop - 10f * densityScaleFallback + val menuTop = + menuBottom - actions.size * buttonHeight - (actions.size - 1).coerceAtLeast(0) * gap + val xInMenu = + pointer.x in (anchorCenterX - buttonWidth / 2f)..(anchorCenterX + buttonWidth / 2f) + + if (pointer.y <= menuTop + buttonHeight / 2f && xInMenu) { + showEditTarget = true + } + + val actionIndex = ((pointer.y - menuTop) / (buttonHeight + gap)).toInt() + val action = actions.getOrNull(actionIndex) + val actionTop = menuTop + actionIndex * (buttonHeight + gap) + val actionHovered = + action != null && xInMenu && pointer.y in actionTop..(actionTop + buttonHeight) + + if (showEditTarget) { + val editTop = menuTop - gap - buttonHeight + val editBottom = editTop + buttonHeight + if (xInMenu && pointer.y in editTop..editBottom) { + hoveredKey = EDIT_QUICK_ACTIONS_KEY + return hoveredKey + } + if (actionHovered && actionIndex > 0) { + showEditTarget = false + } + } + + hoveredKey = if (actionHovered) { + action.id + } else { + null + } + return hoveredKey + } +} + +private fun Modifier.navigationQuickActionDragGesture( + enabled: Boolean, + actions: List, + quickMenuController: NavigationQuickMenuController, + onOpenQuickActions: (anchorCenterX: Float, anchorTop: Float, density: Float) -> Unit, + onDismissQuickActions: () -> Unit, + onOpenEditor: () -> Unit, + onQuickActionSelected: (NavigationQuickTarget) -> Unit, +): Modifier { + if (!enabled) return this + + var rootOffset = Offset.Zero + return this + .onGloballyPositioned { coordinates -> + rootOffset = coordinates.positionInRoot() + } + .pointerInput(actions) { + awaitEachGesture { + val down = + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val longPressTimeout = maxOf( + QUICK_ACTION_HALF_LONG_PRESS_MIN_MS, + viewConfiguration.longPressTimeoutMillis / 2, + ) + val longPressTriggered = withTimeoutOrNull(longPressTimeout) { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } + ?: return@withTimeoutOrNull false + if (!change.pressed) { + return@withTimeoutOrNull false + } + } + } == null + + if (!longPressTriggered) return@awaitEachGesture + + val anchorCenterX = rootOffset.x + size.width / 2f + val anchorTop = rootOffset.y + onOpenQuickActions(anchorCenterX, anchorTop, density) + var latestRootPosition = rootOffset + down.position + quickMenuController.updateHover(latestRootPosition, actions) + + var released = false + while (!released) { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { change -> + if (change.id == down.id) { + latestRootPosition = rootOffset + change.position + quickMenuController.updateHover(latestRootPosition, actions) + if (change.changedToUpIgnoreConsumed()) { + released = true + } + change.consume() + } + } + } + + quickMenuController.updateHover(latestRootPosition, actions) + val hoveredKey = quickMenuController.hoveredKey + quickMenuController.reset() + when (hoveredKey) { + EDIT_QUICK_ACTIONS_KEY -> onOpenEditor() + null -> onDismissQuickActions() + else -> { + val action = actions.firstOrNull { it.id == hoveredKey } + if (action != null) { + onQuickActionSelected(action.target) + } else { + onDismissQuickActions() + } + } + } + } + } +} + +@Composable +private fun NavigationQuickActionEditor( + show: Boolean, + selectedIds: List, + allActions: List, + onDismissRequest: () -> Unit, + onSave: (List) -> Unit, +) { + val allActionLookup = remember(allActions) { allActions.associateBy { it.id } } + val selectedState = remember(show, selectedIds, allActions) { + selectedIds + .mapNotNull { allActionLookup[it] } + .distinctBy { it.id } + .take(MaxNavigationQuickActions) + .map { it.id } + .ifEmpty { DefaultNavigationQuickActionIds } + .toMutableStateList() + } + val selectedLookup = selectedState.toSet() + val availableActions = allActions + .filterNot { it.id in selectedLookup } + .sortedBy { it.label } + val maxHint = stringResource(R.string.navigation_quick_max_hint, MaxNavigationQuickActions) + val bottomPadding = WindowInsets.safeDrawing.asPaddingValues().calculateBottomPadding() + + OverlayBottomSheet( + show = show, + title = stringResource(R.string.navigation_quick_edit_title), + onDismissRequest = onDismissRequest, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 560.dp) + .verticalScroll(rememberScrollState()) + .padding(bottom = bottomPadding + 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = maxHint, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + fontSize = 13.sp, + modifier = Modifier.padding(horizontal = 4.dp), + ) + NavigationQuickEditorSectionTitle(stringResource(R.string.navigation_quick_active_title)) + NavigationQuickEditorDropList( + activeIds = selectedState, + availableActions = availableActions, + allActionLookup = allActionLookup, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + TextButton( + text = stringResource(R.string.navigation_quick_cancel), + modifier = Modifier.weight(1f), + onClick = onDismissRequest, + ) + Button( + onClick = { onSave(selectedState.toList()) }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColorsPrimary(), + ) { + Text(text = stringResource(R.string.navigation_quick_save)) + } + } + } + } +} + +@Composable +private fun NavigationQuickEditorSectionTitle(text: String) { + Text( + text = text, + color = MiuixTheme.colorScheme.onSurface, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 4.dp), + ) +} + +@Composable +private fun NavigationQuickEditorDropList( + activeIds: MutableList, + availableActions: List, + allActionLookup: Map, +) { + val activeBounds = remember { mutableStateMapOf() } + val availableBounds = remember { mutableStateMapOf() } + var activeAreaBounds by remember { mutableStateOf(null) } + var availableAreaBounds by remember { mutableStateOf(null) } + var editorTopInRoot by remember { mutableFloatStateOf(0f) } + var dragState by remember { mutableStateOf(null) } + + fun previewActiveIds(): List { + val state = dragState ?: return activeIds.toList() + if (state.targetArea != NavigationEditorDropArea.Active) { + return activeIds.toList() + } + if (state.sourceArea == NavigationEditorDropArea.Available && activeIds.size >= MaxNavigationQuickActions) { + return activeIds.toList() + } + val preview = activeIds.filterNot { it == state.id }.toMutableList() + preview.add((state.insertIndex ?: preview.size).coerceIn(0, preview.size), state.id) + return preview + } + + fun resetDrag() { + dragState = null + } + + fun updateDrag(pointerRootY: Float) { + val state = dragState ?: return + val targetArea = when { + activeAreaBounds?.let { pointerRootY in it.top..it.bottom } == true -> NavigationEditorDropArea.Active + availableAreaBounds?.let { pointerRootY in it.top..it.bottom } == true -> NavigationEditorDropArea.Available + else -> state.targetArea + } + val insertIndex = if (targetArea == NavigationEditorDropArea.Active) { + findNavigationEditorInsertIndex( + ids = activeIds, + bounds = activeBounds, + draggedCenter = pointerRootY, + excludedId = if (state.sourceArea == NavigationEditorDropArea.Active) state.id else null, + ) + } else { + null + } + dragState = state.copy( + currentPointerRootY = pointerRootY, + targetArea = targetArea, + insertIndex = insertIndex, + ) + } + + fun finishDrag() { + val state = dragState ?: return + when (state.targetArea) { + NavigationEditorDropArea.Active -> { + if (state.sourceArea == NavigationEditorDropArea.Active) { + val fromIndex = activeIds.indexOf(state.id) + if (fromIndex >= 0) { + val item = activeIds.removeAt(fromIndex) + activeIds.add( + (state.insertIndex ?: activeIds.size).coerceIn( + 0, + activeIds.size + ), item + ) + } + } else if (activeIds.size < MaxNavigationQuickActions && state.id !in activeIds) { + activeIds.add( + (state.insertIndex ?: activeIds.size).coerceIn(0, activeIds.size), + state.id + ) + } + } + + NavigationEditorDropArea.Available -> { + if (state.sourceArea == NavigationEditorDropArea.Active) { + activeIds.remove(state.id) + } + } + } + resetDrag() + } + + val isFull = activeIds.size >= MaxNavigationQuickActions + val dragId = dragState?.id + val previewActiveIds = previewActiveIds() + val draggingToFullActive = dragState?.let { + it.sourceArea == NavigationEditorDropArea.Available && + it.targetArea == NavigationEditorDropArea.Active && + isFull + } == true + + Box( + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + editorTopInRoot = coordinates.positionInRoot().y + }, + ) { + Column( + modifier = Modifier.animateContentSize( + animationSpec = tween( + 180, + easing = FastOutSlowInEasing + ) + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + val position = coordinates.positionInRoot() + activeAreaBounds = Rect( + position.x, + position.y, + position.x + coordinates.size.width, + position.y + coordinates.size.height, + ) + } + .then( + if (draggingToFullActive) { + Modifier.border( + width = 1.dp, + color = MiuixTheme.colorScheme.dividerLine.copy(alpha = 0.9f), + shape = RoundedCornerShape(18.dp), + ) + } else { + Modifier + } + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (previewActiveIds.isEmpty()) { + Text( + text = stringResource(R.string.navigation_quick_active_empty), + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + fontSize = 13.sp, + modifier = Modifier.padding(8.dp), + ) + } + previewActiveIds.forEach { id -> + val action = allActionLookup[id] ?: return@forEach + NavigationQuickEditorActionRow( + action = action, + isDragged = false, + isPlaceholder = dragId == id, + modifier = Modifier + .onGloballyPositioned { coordinates -> + val position = coordinates.positionInRoot() + activeBounds[id] = NavigationEditorItemBounds( + top = position.y, + height = coordinates.size.height.toFloat(), + ) + }, + onDragStart = { pointerRootY -> + val bounds = activeBounds[id] + dragState = NavigationEditorDragState( + id = id, + sourceArea = NavigationEditorDropArea.Active, + targetArea = NavigationEditorDropArea.Active, + pointerOffsetInItemY = pointerRootY - (bounds?.top ?: pointerRootY), + currentPointerRootY = pointerRootY, + itemHeight = bounds?.height ?: 46f, + insertIndex = previewActiveIds.indexOf(id).coerceAtLeast(0), + ) + }, + onDrag = { updateDrag(it) }, + onDragEnd = ::finishDrag, + onDragCancel = ::resetDrag, + ) + } + } + + NavigationQuickEditorSectionTitle(stringResource(R.string.navigation_quick_available_title)) + Column( + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + val position = coordinates.positionInRoot() + availableAreaBounds = Rect( + position.x, + position.y, + position.x + coordinates.size.width, + position.y + coordinates.size.height, + ) + }, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (availableActions.isEmpty()) { + Text( + text = stringResource(R.string.navigation_quick_available_empty), + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + fontSize = 13.sp, + modifier = Modifier.padding(8.dp), + ) + } + availableActions.forEach { action -> + NavigationQuickEditorActionRow( + action = action, + isDragged = false, + isPlaceholder = dragId == action.id, + modifier = Modifier.onGloballyPositioned { coordinates -> + val position = coordinates.positionInRoot() + availableBounds[action.id] = NavigationEditorItemBounds( + top = position.y, + height = coordinates.size.height.toFloat(), + ) + }, + onDragStart = { pointerRootY -> + val bounds = availableBounds[action.id] + dragState = NavigationEditorDragState( + id = action.id, + sourceArea = NavigationEditorDropArea.Available, + targetArea = NavigationEditorDropArea.Available, + pointerOffsetInItemY = pointerRootY - (bounds?.top ?: pointerRootY), + currentPointerRootY = pointerRootY, + itemHeight = bounds?.height ?: 46f, + ) + }, + onDrag = { updateDrag(it) }, + onDragEnd = ::finishDrag, + onDragCancel = ::resetDrag, + ) + } + } + } + + dragState?.let { state -> + val action = + allActionLookup[state.id] ?: availableActions.firstOrNull { it.id == state.id } + if (action != null) { + NavigationQuickEditorActionRow( + action = action, + isDragged = true, + isPlaceholder = false, + dragEnabled = false, + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { + translationY = + state.currentPointerRootY - state.pointerOffsetInItemY - editorTopInRoot + } + .zIndex(8f), + onDragStart = {}, + onDrag = {}, + onDragEnd = {}, + onDragCancel = {}, + ) + } + } + } +} + +@Composable +private fun NavigationQuickEditorActionRow( + action: NavigationQuickAction, + isDragged: Boolean, + modifier: Modifier = Modifier, + isPlaceholder: Boolean = false, + dragEnabled: Boolean = true, + onDragStart: (Float) -> Unit, + onDrag: (Float) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> Unit, +) { + val scale by animateFloatAsState( + targetValue = if (isDragged) 1.03f else 1f, + animationSpec = spring(dampingRatio = 0.75f, stiffness = 360f), + label = "EditorRowScale", + ) + val alpha by animateFloatAsState( + targetValue = if (isPlaceholder) 0.2f else 1f, + animationSpec = tween(180, easing = FastOutSlowInEasing), + label = "EditorRowAlpha", + ) + val contentColor = MiuixTheme.colorScheme.onSurfaceContainer + Row( + modifier = modifier + .fillMaxWidth() + .animateContentSize(animationSpec = tween(180, easing = FastOutSlowInEasing)) + .height(46.dp) + .graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = alpha + shape = ContinuousCapsule + clip = false + } + .background(MiuixTheme.colorScheme.surfaceContainer, ContinuousCapsule) + .border( + 0.75.dp, + MiuixTheme.colorScheme.dividerLine.copy(alpha = 0.72f), + ContinuousCapsule + ) + .padding(start = 14.dp, end = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = action.icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(20.dp), + ) + AutoShrinkText( + text = action.label, + color = contentColor, + maxFontSize = 13.sp, + minFontSize = 9.sp, + modifier = Modifier.weight(1f), + ) + NavigationQuickDragHandle( + enabled = dragEnabled, + onDragStart = onDragStart, + onDrag = onDrag, + onDragEnd = onDragEnd, + onDragCancel = onDragCancel, + ) + } +} + +@Composable +private fun NavigationQuickDragHandle( + enabled: Boolean, + onDragStart: (Float) -> Unit, + onDrag: (Float) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> Unit, +) { + var handleTopInRoot by remember { mutableFloatStateOf(0f) } + Box( + modifier = Modifier + .size(width = 36.dp, height = 40.dp) + .onGloballyPositioned { coordinates -> + handleTopInRoot = coordinates.positionInRoot().y + } + .then( + if (enabled) { + Modifier.pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val downRootY = handleTopInRoot + down.position.y + onDragStart(downRootY) + onDrag(downRootY) + var cancelled = false + var dragging = true + while (dragging) { + val event = awaitPointerEvent() + event.changes.forEach { change -> + if (change.changedToUpIgnoreConsumed()) { + dragging = false + } else if (change.pressed) { + onDrag(handleTopInRoot + change.position.y) + change.consume() + } else { + cancelled = true + dragging = false + } + } + } + if (cancelled) onDragCancel() else onDragEnd() + } + } + } else { + Modifier + } + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Menu, + contentDescription = null, + tint = MiuixTheme.colorScheme.onSurfaceContainer.copy(alpha = 0.62f), + modifier = Modifier.size(22.dp), + ) + } +} + +private enum class NavigationEditorDropArea { + Active, + Available, +} + +private data class NavigationEditorDragState( + val id: String, + val sourceArea: NavigationEditorDropArea, + val targetArea: NavigationEditorDropArea, + val pointerOffsetInItemY: Float, + val currentPointerRootY: Float, + val itemHeight: Float, + val insertIndex: Int? = null, +) + +private data class NavigationEditorItemBounds( + val top: Float, + val height: Float, +) + +private fun findNavigationEditorInsertIndex( + ids: List, + bounds: Map, + draggedCenter: Float, + excludedId: String? = null, +): Int { + val filteredIds = ids.filterNot { it == excludedId } + val boundaries = filteredIds.mapNotNull { id -> + bounds[id]?.let { it.top + it.height / 2f } + } + return boundaries.count { draggedCenter > it }.coerceIn(0, filteredIds.size) +} + +private class NavigationQuickActionsPopupPositionProvider( + private val spacingPx: Int, + private val windowPaddingPx: Int, +) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val preferredX = anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2 + val minX = windowPaddingPx + val maxX = (windowSize.width - popupContentSize.width - windowPaddingPx) + .coerceAtLeast(minX) + val x = preferredX.coerceIn(minX, maxX) + + val preferredY = anchorBounds.top - popupContentSize.height - spacingPx + val maxY = (windowSize.height - popupContentSize.height - windowPaddingPx) + .coerceAtLeast(windowPaddingPx) + val fallbackY = anchorBounds.bottom + spacingPx + val y = if (preferredY >= windowPaddingPx) { + preferredY + } else { + fallbackY.coerceIn(windowPaddingPx, maxY) + } + + return IntOffset(x, y) + } +} diff --git a/app/src/main/java/hk/uwu/reareye/ui/config/Config.kt b/app/src/main/java/hk/uwu/reareye/ui/config/Config.kt index b8a26cc..9f5f738 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/config/Config.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/config/Config.kt @@ -196,6 +196,8 @@ sealed class ConfigType { SCENE_ROUTE, CARD, BUSINESS_EXTRA, + BOUNDS, + LYRICS, } data class Manager(val managerType: ManagerType) : ConfigType() { diff --git a/app/src/main/java/hk/uwu/reareye/ui/config/ModuleConfig.kt b/app/src/main/java/hk/uwu/reareye/ui/config/ModuleConfig.kt index 7634115..e86179a 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/config/ModuleConfig.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/config/ModuleConfig.kt @@ -23,9 +23,14 @@ object ConfigKeys { const val ACTIVITIES_WHITELIST_APPS = "activities_whitelist_apps" const val ALLOW_ALL_ACTIVITIES = "allow_all_activities" const val HOOK_SKIP_LOCK_BACK_HOME = "enable_skip_lock_back_home" + const val CFG_CUSTOM_BOUNDS_COMPAT_MANAGER = "cfg_custom_bounds_compat_manager" + const val CUSTOM_BOUNDS_COMPAT_APPS = "custom_bounds_compat_apps" + const val CUSTOM_BOUNDS_COMPAT_CONFIG_DATA = "custom_bounds_compat_config_data" const val HOOK_MUSIC_CONTROLS_WHITELIST = "enable_music_controls_whitelist_hook" const val MUSIC_CONTROLS_WHITELIST_APPS = "music_controls_whitelist_apps" + const val SUBSCREEN_LOCK_BACK_HOME_WHITELIST_APPS = + "subscreen_lock_back_home_whitelist_apps" const val HOOK_MUSIC_CONTROLS_FORCE_UPDATE = "enable_music_controls_force_update" const val HOOK_VIDEO_LOOPING = "enable_video_looping" @@ -73,9 +78,16 @@ object ConfigKeys { "enable_take_over_builtin_lyric_handling" const val HOOK_DISABLE_REAR_SCREEN_COVER = "enable_hook_rear_screen_cover" + const val SUBSCREEN_DOUBLE_TAP_SLEEP_DISABLED_APPS = + "subscreen_double_tap_sleep_disabled_apps" + const val SUBSCREEN_DOUBLE_TAP_WAKE_DISABLED_APPS = + "subscreen_double_tap_wake_disabled_apps" + const val SUBSCREEN_HIGH_LOAD_MODE_DISABLED_APPS = + "subscreen_high_load_mode_disabled_apps" const val MORE_DEBUG = "enable_more_debug_logging" const val MODULE_FAVORITE_CONFIG_NODES = "module_favorite_config_nodes" + const val MODULE_NAVIGATION_QUICK_ACTIONS = "module_navigation_quick_actions" } enum class ModuleNavigationBarMode( @@ -93,6 +105,11 @@ enum class ModuleNavigationBarMode( FLOATING_GLASS( value = 2, titleRes = R.string.module_navigation_bar_mode_floating_glass, + ), + + SEMI_TRANSPARENT( + value = 3, + titleRes = R.string.module_navigation_bar_mode_semi_transparent, ); companion object { @@ -101,6 +118,7 @@ enum class ModuleNavigationBarMode( NORMAL, FLOATING, FLOATING_GLASS, + SEMI_TRANSPARENT, ) fun fromValue(value: Int): ModuleNavigationBarMode { @@ -289,11 +307,35 @@ val REAREyeConfig = listOf( titleRes = R.string.skip_lock_back_home, type = ConfigType.BooleanVal(defaultValue = false) ), + ConfigItem( + key = ConfigKeys.CFG_CUSTOM_BOUNDS_COMPAT_MANAGER, + titleRes = R.string.custom_bounds_compat_manager, + descriptionRes = R.string.custom_bounds_compat_manager_desc, + type = ConfigType.Manager(ConfigType.ManagerType.BOUNDS), + ), ConfigItem( key = ConfigKeys.HOOK_DISABLE_REAR_SCREEN_COVER, titleRes = R.string.cfg_disable_rear_screen_cover, descriptionRes = R.string.cfg_disable_rear_screen_cover_desc, type = ConfigType.BooleanVal(defaultValue = false) + ), + ConfigItem( + key = ConfigKeys.SUBSCREEN_DOUBLE_TAP_SLEEP_DISABLED_APPS, + titleRes = R.string.cfg_disable_subscreen_double_tap_sleep, + descriptionRes = R.string.cfg_disable_subscreen_double_tap_sleep_desc, + type = ConfigType.AppList(defaultValues = emptySet()) + ), + ConfigItem( + key = ConfigKeys.SUBSCREEN_DOUBLE_TAP_WAKE_DISABLED_APPS, + titleRes = R.string.cfg_disable_subscreen_double_tap_wake, + descriptionRes = R.string.cfg_disable_subscreen_double_tap_wake_desc, + type = ConfigType.AppList(defaultValues = emptySet()) + ), + ConfigItem( + key = ConfigKeys.SUBSCREEN_HIGH_LOAD_MODE_DISABLED_APPS, + titleRes = R.string.cfg_disable_subscreen_high_load_mode, + descriptionRes = R.string.cfg_disable_subscreen_high_load_mode_desc, + type = ConfigType.AppList(defaultValues = emptySet()) ) ) ), @@ -370,6 +412,18 @@ val REAREyeConfig = listOf( ) ) ), + ConfigCategory( + titleRes = R.string.cfg_subscreen_lock_back_home_whitelist, + descriptionRes = R.string.cfg_subscreen_lock_back_home_whitelist_desc, + children = listOf( + ConfigItem( + key = ConfigKeys.SUBSCREEN_LOCK_BACK_HOME_WHITELIST_APPS, + titleRes = R.string.subscreen_lock_back_home_whitelist_apps, + descriptionRes = R.string.subscreen_lock_back_home_whitelist_apps_desc, + type = ConfigType.AppList(defaultValues = emptySet()) + ) + ) + ), ConfigCategory( titleRes = R.string.subcategory_lyrics, descriptionRes = R.string.subcategory_lyrics_desc, diff --git a/app/src/main/java/hk/uwu/reareye/ui/config/PrefsManager.kt b/app/src/main/java/hk/uwu/reareye/ui/config/PrefsManager.kt index 76a9844..141797a 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/config/PrefsManager.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/config/PrefsManager.kt @@ -42,6 +42,12 @@ class PrefsManager(val prefs: YukiHookPrefsBridge) { return prefs.getFloat(key, defValue) } + fun getRequirementValue(key: String): Any? { + val nativePrefs = prefs.native() + if (!nativePrefs.contains(key)) return null + return nativePrefs.all()[key] + } + fun putFloat(key: String, value: Float) { prefs.edit().putFloat(key, value).apply() } @@ -50,4 +56,4 @@ class PrefsManager(val prefs: YukiHookPrefsBridge) { fun Context.getPrefsManager() = PrefsManager(this.prefs()) fun YukiHookPrefsBridge.getPrefsManager() = PrefsManager(this) } -} \ No newline at end of file +} diff --git a/app/src/main/java/hk/uwu/reareye/ui/screen/AboutScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/screen/AboutScreen.kt index 178574d..eb5aa1b 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/screen/AboutScreen.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/screen/AboutScreen.kt @@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -35,6 +37,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape @@ -46,7 +49,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -91,17 +93,24 @@ import hk.uwu.reareye.ui.theme.rememberAcrylicHazeState import hk.uwu.reareye.ui.theme.rememberAcrylicHazeStyle import hk.uwu.reareye.utils.blend.ColorBlendToken import hk.uwu.reareye.utils.effect.BgEffectBackground +import hk.uwu.reareye.utils.other.DeviceConfigTools +import hk.uwu.reareye.utils.other.LibraryItem +import hk.uwu.reareye.utils.other.OSVersionTools +import hk.uwu.reareye.utils.other.loadLibraries +import hk.uwu.reareye.utils.pageContentPadding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request +import top.yukonga.miuix.kmp.basic.BasicComponent +import top.yukonga.miuix.kmp.basic.BasicComponentDefaults import top.yukonga.miuix.kmp.basic.Button import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.CardDefaults -import top.yukonga.miuix.kmp.basic.CircularProgressIndicator import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.ScrollBehavior @@ -117,15 +126,24 @@ import top.yukonga.miuix.kmp.blur.rememberLayerBackdrop import top.yukonga.miuix.kmp.blur.textureBlur import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.extended.Create +import top.yukonga.miuix.kmp.icon.extended.Info import top.yukonga.miuix.kmp.icon.extended.Link import top.yukonga.miuix.kmp.shapes.SmoothRoundedCornerShape import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme import top.yukonga.miuix.kmp.utils.overScrollVertical import top.yukonga.miuix.kmp.utils.scrollEndHaptic -import utils.pageContentPadding import java.util.concurrent.ConcurrentHashMap +private val AboutPageHorizontalPadding = 12.dp +private val AboutDeviceInfoCardTopPadding = 20.dp +private val AboutDeviceInfoCardBottomPadding = 12.dp +private val AboutDeviceInfoRowVerticalPadding = 8.dp +private val AboutDeviceInfoHeaderBottomSpacing = 8.dp +private val AboutCardSpacing = 8.dp +private val AboutGradientFadeDistance = 389.dp + private val contributorAvatarHttpClient = OkHttpClient() + private object ContributorAvatarCache { private val cache = ConcurrentHashMap() @@ -166,8 +184,14 @@ private object ContributorAvatarCache { private sealed interface AboutRoute { data object Root : AboutRoute data object Contributors : AboutRoute + data object Licenses : AboutRoute } +private data class AboutAnimatedRoute( + val route: AboutRoute, + val depth: Int, +) + private data class CreditEntry( val titleRes: Int, val summaryRes: Int, @@ -237,27 +261,17 @@ private fun rememberSkeletonPulseAlpha(label: String): Float { @Composable fun AboutScreen(bottomInnerPadding: Dp = 0.dp) { - val layoutDirection = LocalLayoutDirection.current - val scrollBehavior = MiuixScrollBehavior() - val hazeState = rememberAcrylicHazeState() - val hazeStyle = rememberAcrylicHazeStyle() val versionText = rememberVersionText() - var logoHeightPx by remember { mutableIntStateOf(0) } val contributorState by ContributorRepository.state.collectAsState() val lazyListState = rememberLazyListState() var route by remember { mutableStateOf(AboutRoute.Root) } - - val scrollProgress by remember { - derivedStateOf { - if (logoHeightPx <= 0) { - 0f - } else { - val index = lazyListState.firstVisibleItemIndex - val offset = lazyListState.firstVisibleItemScrollOffset - if (index > 0) 1f else (offset.toFloat() / logoHeightPx).coerceIn(0f, 1f) - } - } + var animateRootContent by remember { mutableStateOf(true) } + val animatedRoute = remember(route) { + AboutAnimatedRoute( + route = route, + depth = if (route is AboutRoute.Root) 0 else 1, + ) } val entries = remember { @@ -307,112 +321,193 @@ fun AboutScreen(bottomInnerPadding: Dp = 0.dp) { ) } - BackHandler(enabled = route is AboutRoute.Contributors) { + BackHandler(enabled = route is AboutRoute.Contributors || route is AboutRoute.Licenses) { route = AboutRoute.Root } - Scaffold( - topBar = { - if (route is AboutRoute.Root) { - SmallTopAppBar( - title = stringResource(R.string.about_navigation), - scrollBehavior = scrollBehavior, - color = colorScheme.surface.copy( - alpha = if (scrollProgress == 1f) 1f else 0f - ), - titleColor = colorScheme.onSurface.copy(alpha = scrollProgress), - defaultWindowInsetsPadding = false, - navigationIconPadding = 12.dp, + AnimatedContent( + modifier = Modifier + .fillMaxSize() + .background(colorScheme.surface) + .graphicsLayer { clip = true }, + targetState = animatedRoute, + contentKey = { it.route }, + transitionSpec = { + val forward = targetState.depth >= initialState.depth + + fadeIn( + animationSpec = tween( + durationMillis = 210, + delayMillis = 50, + easing = LinearOutSlowInEasing, ) - } else { - TopAppBar( - modifier = Modifier.rearAcrylicEffect(hazeState, hazeStyle), - color = Color.Transparent, - title = stringResource(R.string.credits_contributors_title), - navigationIcon = { - IconButton(onClick = { route = AboutRoute.Root }) { - Icon( - modifier = Modifier.graphicsLayer { - if (layoutDirection == LayoutDirection.Rtl) scaleX = -1f - }, - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } - }, - navigationIconPadding = 12.dp, - scrollBehavior = scrollBehavior, + ) + slideInHorizontally( + animationSpec = tween( + durationMillis = 280, + easing = FastOutSlowInEasing, ) - } - } - ) { paddingValues -> - AnimatedContent( - modifier = Modifier - .fillMaxSize() - .background(colorScheme.surface) - .graphicsLayer { clip = true }, - targetState = route, - contentKey = { it }, - transitionSpec = { - val forward = targetState is AboutRoute.Contributors - - fadeIn( - animationSpec = tween( - durationMillis = 210, - delayMillis = 50, - easing = LinearOutSlowInEasing, - ) - ) + slideInHorizontally( - animationSpec = tween( - durationMillis = 280, - easing = FastOutSlowInEasing, - ) - ) { fullWidth -> - if (forward) fullWidth / 9 else -fullWidth / 9 - } togetherWith ( - fadeOut( - animationSpec = tween( - durationMillis = 110, - easing = FastOutLinearInEasing, - ) - ) + slideOutHorizontally( - animationSpec = tween( - durationMillis = 190, - easing = FastOutLinearInEasing, - ) - ) { fullWidth -> - if (forward) -fullWidth / 12 else fullWidth / 12 - } + ) { fullWidth -> + if (forward) fullWidth / 9 else -fullWidth / 9 + } togetherWith ( + fadeOut( + animationSpec = tween( + durationMillis = 110, + easing = FastOutLinearInEasing, ) - }, - label = "AboutRouteTransition", - ) { currentRoute -> - when (currentRoute) { - AboutRoute.Root -> AboutRootContent( + ) + slideOutHorizontally( + animationSpec = tween( + durationMillis = 190, + easing = FastOutLinearInEasing, + ) + ) { fullWidth -> + if (forward) -fullWidth / 12 else fullWidth / 12 + } + ) + }, + label = "AboutRouteTransition", + ) { target -> + when (target.route) { + AboutRoute.Root -> AboutRootPage( + bottomInnerPadding = bottomInnerPadding, + versionText = versionText, + entries = entries, + lazyListState = lazyListState, + animateEnter = animateRootContent, + onOpenContributors = { + animateRootContent = false + route = AboutRoute.Contributors + }, + onOpenLibraries = { + animateRootContent = false + route = AboutRoute.Licenses + }, + ) + + AboutRoute.Contributors -> AboutSecondaryPage( + title = stringResource(R.string.credits_contributors_title), + onBack = { route = AboutRoute.Root }, + ) { paddingValues, scrollBehavior, hazeState -> + ContributorListContent( bottomInnerPadding = bottomInnerPadding, paddingValues = paddingValues, scrollBehavior = scrollBehavior, hazeState = hazeState, - versionText = versionText, - entries = entries, - onOpenContributors = { route = AboutRoute.Contributors }, - lazyListState = lazyListState, - scrollProgress = scrollProgress, - onLogoHeightChanged = { logoHeightPx = it } + state = contributorState, ) + } - AboutRoute.Contributors -> ContributorListContent( + AboutRoute.Licenses -> AboutSecondaryPage( + title = stringResource(R.string.licenses_name), + onBack = { route = AboutRoute.Root }, + ) { paddingValues, scrollBehavior, hazeState -> + LicenseContent( bottomInnerPadding = bottomInnerPadding, paddingValues = paddingValues, scrollBehavior = scrollBehavior, hazeState = hazeState, - state = contributorState, ) } } } } +@Composable +private fun AboutRootPage( + bottomInnerPadding: Dp, + versionText: String, + entries: List, + lazyListState: LazyListState, + animateEnter: Boolean, + onOpenContributors: () -> Unit, + onOpenLibraries: () -> Unit, +) { + val scrollBehavior = MiuixScrollBehavior() + val hazeState = rememberAcrylicHazeState() + val gradientFadeDistancePx = with(LocalDensity.current) { + AboutGradientFadeDistance.toPx().coerceAtLeast(1f) + } + val scrollProgress by remember(lazyListState, gradientFadeDistancePx) { + derivedStateOf { + val index = lazyListState.firstVisibleItemIndex + val offset = lazyListState.firstVisibleItemScrollOffset + + if (index > 0) { + 1f + } else { + (offset.toFloat() / gradientFadeDistancePx).coerceIn(0f, 1f) + } + } + } + + Scaffold( + topBar = { + SmallTopAppBar( + title = stringResource(R.string.about_navigation), + scrollBehavior = scrollBehavior, + color = colorScheme.surface.copy(alpha = if (scrollProgress == 1f) 1f else 0f), + titleColor = colorScheme.onSurface.copy(alpha = scrollProgress), + defaultWindowInsetsPadding = false, + navigationIconPadding = 12.dp, + ) + } + ) { paddingValues -> + AboutRootContent( + bottomInnerPadding = bottomInnerPadding, + paddingValues = paddingValues, + scrollBehavior = scrollBehavior, + hazeState = hazeState, + versionText = versionText, + entries = entries, + onOpenContributors = onOpenContributors, + scrollProgress = scrollProgress, + lazyListState = lazyListState, + onOpenLibraries = onOpenLibraries, + animateEnter = animateEnter, + ) + } +} + +@Composable +private fun AboutSecondaryPage( + title: String, + onBack: () -> Unit, + content: @Composable (PaddingValues, ScrollBehavior, HazeState) -> Unit, +) { + val layoutDirection = LocalLayoutDirection.current + val scrollBehavior = MiuixScrollBehavior() + val hazeState = rememberAcrylicHazeState() + val hazeStyle = rememberAcrylicHazeStyle() + + Scaffold( + topBar = { + TopAppBar( + modifier = Modifier.rearAcrylicEffect(hazeState, hazeStyle), + color = Color.Transparent, + title = title, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + modifier = Modifier.graphicsLayer { + if (layoutDirection == LayoutDirection.Rtl) scaleX = -1f + }, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + }, + navigationIconPadding = 12.dp, + scrollBehavior = scrollBehavior, + ) + } + ) { paddingValues -> + content( + paddingValues, + scrollBehavior, + hazeState, + ) + } +} + @Composable private fun AboutRootContent( bottomInnerPadding: Dp, @@ -423,8 +518,9 @@ private fun AboutRootContent( entries: List, onOpenContributors: () -> Unit, scrollProgress: Float, - onLogoHeightChanged: (Int) -> Unit, lazyListState: LazyListState, + onOpenLibraries: () -> Unit, + animateEnter: Boolean, ) { val context = LocalContext.current val visualTokens = rememberAboutVisualTokens() @@ -435,16 +531,20 @@ private fun AboutRootContent( paddingValues, paddingValues, false, - extraStart = WindowInsets.displayCutout.asPaddingValues().calculateLeftPadding(LayoutDirection.Ltr), - extraEnd = WindowInsets.displayCutout.asPaddingValues().calculateRightPadding(LayoutDirection.Ltr), + extraStart = WindowInsets.displayCutout.asPaddingValues() + .calculateLeftPadding(LayoutDirection.Ltr), + extraEnd = WindowInsets.displayCutout.asPaddingValues() + .calculateRightPadding(LayoutDirection.Ltr), ) val logoPadding = pageContentPadding( paddingValues, paddingValues, false, extraTop = 10.dp, - extraStart = WindowInsets.displayCutout.asPaddingValues().calculateLeftPadding(LayoutDirection.Ltr), - extraEnd = WindowInsets.displayCutout.asPaddingValues().calculateRightPadding(LayoutDirection.Ltr), + extraStart = WindowInsets.displayCutout.asPaddingValues() + .calculateLeftPadding(LayoutDirection.Ltr), + extraEnd = WindowInsets.displayCutout.asPaddingValues() + .calculateRightPadding(LayoutDirection.Ltr), ) val density = LocalDensity.current @@ -480,12 +580,19 @@ private fun AboutRootContent( val stage3TotalLength = projectNameY - iconY val versionCodeDelay = stage1TotalLength * 0.5f - versionCodeProgress = ((offset.toFloat() - versionCodeDelay) / (stage1TotalLength - versionCodeDelay).coerceAtLeast(1f)) - .coerceIn(0f, 1f) - projectNameProgress = ((offset.toFloat() - stage1TotalLength) / stage2TotalLength.coerceAtLeast(1f)) - .coerceIn(0f, 1f) - iconProgress = ((offset.toFloat() - stage1TotalLength - stage2TotalLength) / stage3TotalLength.coerceAtLeast(1f)) - .coerceIn(0f, 1f) + versionCodeProgress = + ((offset.toFloat() - versionCodeDelay) / (stage1TotalLength - versionCodeDelay).coerceAtLeast( + 1f + )) + .coerceIn(0f, 1f) + projectNameProgress = + ((offset.toFloat() - stage1TotalLength) / stage2TotalLength.coerceAtLeast(1f)) + .coerceIn(0f, 1f) + iconProgress = + ((offset.toFloat() - stage1TotalLength - stage2TotalLength) / stage3TotalLength.coerceAtLeast( + 1f + )) + .coerceIn(0f, 1f) } .collect { } } @@ -608,7 +715,7 @@ private fun AboutRootContent( .overScrollVertical() .nestedScroll(scrollBehavior.nestedScrollConnection) .rearAcrylicSource(hazeState) - .padding(horizontal = 12.dp), + .padding(horizontal = AboutPageHorizontalPadding), contentPadding = PaddingValues( top = scrollPadding.calculateTopPadding(), bottom = paddingValues.calculateBottomPadding() + bottomInnerPadding, @@ -622,9 +729,6 @@ private fun AboutRootContent( .height( logoHeightDp + 52.dp + logoPadding.calculateTopPadding() - scrollPadding.calculateTopPadding() + 126.dp, ) - .onSizeChanged { size -> - onLogoHeightChanged(size.height) - } .onGloballyPositioned { coordinates -> val y = coordinates.positionInWindow().y val size = coordinates.size @@ -638,61 +742,116 @@ private fun AboutRootContent( item(key = "content") { Column( modifier = Modifier - .fillParentMaxHeight() + .fillMaxWidth() .padding(bottom = scrollPadding.calculateBottomPadding()), + verticalArrangement = Arrangement.spacedBy(AboutCardSpacing), ) { - ArtStaggeredReveal( - visible = true, - revealKey = "contributors", - delayMillis = 36, + AboutDeviceInfoCard( + context = context, + backdrop = backdrop, + visualTokens = visualTokens, + animateEnter = animateEnter, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(AboutCardSpacing), ) { - Card( - modifier = Modifier - .padding(horizontal = 8.dp) - .textureBlur( - backdrop = backdrop, - shape = SmoothRoundedCornerShape(16.dp), - blurRadius = 60f, - noiseCoefficient = 0.001f, - colors = BlurColors( - blendColors = visualTokens.cardBlendColors, - brightness = 0f, - contrast = 1f, - saturation = 1f, + AboutReveal( + enabled = animateEnter, + revealKey = "contributors", + delayMillis = 36, + ) { + Card( + modifier = Modifier + .textureBlur( + backdrop = backdrop, + shape = SmoothRoundedCornerShape(16.dp), + blurRadius = 60f, + noiseCoefficient = 0.001f, + colors = BlurColors( + blendColors = visualTokens.cardBlendColors, + brightness = 0f, + contrast = 1f, + saturation = 1f, + ), + enabled = true, ), - enabled = true, + colors = CardDefaults.defaultColors( + Color.Transparent, + Color.Transparent, ), - colors = CardDefaults.defaultColors( - Color.Transparent, - Color.Transparent, - ), - ) { - SuperCard( - title = stringResource(R.string.credits_contributors_title), - summary = stringResource(R.string.credits_contributors_desc), - onClick = onOpenContributors, - endActions = { - Icon( - imageVector = MiuixIcons.Create, - tint = colorScheme.onSurface, - contentDescription = null, - ) - }, - ) + ) { + SuperCard( + title = stringResource(R.string.credits_contributors_title), + summary = stringResource(R.string.credits_contributors_desc), + onClick = onOpenContributors, + endActions = { + Icon( + imageVector = MiuixIcons.Create, + tint = colorScheme.onSurface, + contentDescription = null + ) + } + ) + } } - } + entries.forEachIndexed { index, entry -> + AboutReveal( + enabled = animateEnter, + revealKey = entry.url, + delayMillis = (54 + index * 18).coerceAtMost(150), + ) { + Card( + modifier = Modifier + .textureBlur( + backdrop = backdrop, + shape = SmoothRoundedCornerShape(16.dp), + blurRadius = 60f, + noiseCoefficient = 0.001f, + colors = BlurColors( + blendColors = visualTokens.cardBlendColors, + brightness = 0f, + contrast = 1f, + saturation = 1f, + ), + enabled = true, + ), + colors = CardDefaults.defaultColors( + Color.Transparent, + Color.Transparent, + ), + ) { + SuperCard( + title = stringResource(entry.titleRes), + summary = stringResource(entry.summaryRes), + onClick = { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + entry.url.toUri() + ) + ) + }, + endActions = { + Icon( + imageVector = MiuixIcons.Link, + tint = colorScheme.onSurface, + contentDescription = null + ) + } + ) + } + } + } - entries.forEachIndexed { index, entry -> - ArtStaggeredReveal( - visible = true, - revealKey = entry.url, - delayMillis = (54 + index * 18).coerceAtMost(150), + AboutReveal( + enabled = animateEnter, + revealKey = "licenses", + delayMillis = (54 + entries.size * 18).coerceAtMost(150), ) { Card( modifier = Modifier - .padding(top = 8.dp) - .padding(horizontal = 8.dp) .textureBlur( backdrop = backdrop, shape = SmoothRoundedCornerShape(16.dp), @@ -712,14 +871,12 @@ private fun AboutRootContent( ), ) { SuperCard( - title = stringResource(entry.titleRes), - summary = stringResource(entry.summaryRes), - onClick = { - context.startActivity(Intent(Intent.ACTION_VIEW, entry.url.toUri())) - }, + title = stringResource(R.string.licenses_name), + summary = stringResource(R.string.licenses_name_summary), + onClick = onOpenLibraries, endActions = { Icon( - imageVector = MiuixIcons.Link, + imageVector = MiuixIcons.Info, tint = colorScheme.onSurface, contentDescription = null ) @@ -735,6 +892,120 @@ private fun AboutRootContent( } } +@Composable +private fun AboutDeviceInfoCard( + context: android.content.Context, + backdrop: top.yukonga.miuix.kmp.blur.LayerBackdrop, + visualTokens: AboutVisualTokens, + animateEnter: Boolean, +) { + AboutReveal( + enabled = animateEnter, + revealKey = "device_info_card", + delayMillis = 0, + ) { + val layoutDirection = LocalLayoutDirection.current + val deviceInfoCardPadding = PaddingValues( + start = BasicComponentDefaults.InsideMargin.calculateStartPadding(layoutDirection), + top = AboutDeviceInfoCardTopPadding, + end = BasicComponentDefaults.InsideMargin.calculateEndPadding(layoutDirection), + bottom = AboutDeviceInfoCardBottomPadding, + ) + val deviceInfoRowPadding = PaddingValues(vertical = AboutDeviceInfoRowVerticalPadding) + val subscreenVer = DeviceConfigTools.getSubScreenVersion(context) + + Card( + modifier = Modifier + .textureBlur( + backdrop = backdrop, + shape = SmoothRoundedCornerShape(16.dp), + blurRadius = 60f, + noiseCoefficient = 0.001f, + colors = BlurColors( + blendColors = visualTokens.cardBlendColors, + brightness = 0f, + contrast = 1f, + saturation = 1f, + ), + enabled = true, + ), + colors = CardDefaults.defaultColors( + Color.Transparent, + Color.Transparent, + ), + ) { + Column( + modifier = Modifier.padding(deviceInfoCardPadding), + ) { + Text( + text = DeviceConfigTools.deviceName, + modifier = Modifier.padding(bottom = AboutDeviceInfoHeaderBottomSpacing), + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + color = BasicComponentDefaults.titleColor().color, + ) + + BasicComponent( + title = stringResource(R.string.device_name), + summary = DeviceConfigTools.marketName, + insideMargin = deviceInfoRowPadding, + ) + + BasicComponent( + title = stringResource(R.string.android_version), + summary = DeviceConfigTools.androidVersion, + insideMargin = deviceInfoRowPadding, + ) + + BasicComponent( + title = stringResource(R.string.os_version), + summary = OSVersionTools.addVersionSuffix(context), + insideMargin = deviceInfoRowPadding, + ) + + BasicComponent( + title = stringResource(R.string.module_version_label), + summary = rememberVersionText(), + insideMargin = deviceInfoRowPadding, + ) + + BasicComponent( + title = stringResource(R.string.version_codename_label), + summary = AppProperties.PROJECT_APP_VERSION_CODENAME, + insideMargin = deviceInfoRowPadding, + ) + + if (subscreenVer != "UNKNOWN") { + BasicComponent( + title = stringResource(R.string.subsceen_version), + summary = subscreenVer, + insideMargin = deviceInfoRowPadding, + ) + } + } + } + } +} + +@Composable +private fun AboutReveal( + enabled: Boolean, + revealKey: Any, + delayMillis: Int, + content: @Composable () -> Unit, +) { + if (enabled) { + ArtStaggeredReveal( + visible = true, + revealKey = revealKey, + delayMillis = delayMillis, + content = content, + ) + } else { + content() + } +} + @Composable private fun ContributorListContent( bottomInnerPadding: Dp, @@ -752,12 +1023,12 @@ private fun ContributorListContent( .overScrollVertical() .nestedScroll(scrollBehavior.nestedScrollConnection) .rearAcrylicSource(hazeState) - .padding(horizontal = 12.dp), + .padding(horizontal = AboutPageHorizontalPadding), contentPadding = PaddingValues( top = paddingValues.calculateTopPadding(), bottom = paddingValues.calculateBottomPadding() + bottomInnerPadding, ), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(AboutCardSpacing), overscrollEffect = null, ) { item { @@ -777,7 +1048,7 @@ private fun ContributorListContent( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator() + InfiniteProgressIndicator() Text(text = stringResource(R.string.credits_contributors_loading)) } } @@ -838,6 +1109,41 @@ private fun ContributorListContent( } } +@Composable +private fun LicenseContent( + bottomInnerPadding: Dp, + paddingValues: PaddingValues, + scrollBehavior: ScrollBehavior, + hazeState: HazeState, +) { + val context = LocalContext.current + val data = remember { + loadLibraries(context) + } + + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .scrollEndHaptic() + .overScrollVertical() + .nestedScroll(scrollBehavior.nestedScrollConnection) + .rearAcrylicSource(hazeState) + .padding(horizontal = 12.dp), + contentPadding = PaddingValues( + top = paddingValues.calculateTopPadding(), + bottom = paddingValues.calculateBottomPadding() + bottomInnerPadding, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + overscrollEffect = null, + ) { + items(data.libraries) { + LibraryItem(it, data.licenses) + } + } + +} + @Composable private fun ContributorCard( item: ContributorProfile, diff --git a/app/src/main/java/hk/uwu/reareye/ui/screen/ConfigScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/screen/ConfigScreen.kt index 0154a93..e94a312 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/screen/ConfigScreen.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/screen/ConfigScreen.kt @@ -1,5 +1,6 @@ package hk.uwu.reareye.ui.screen +import android.annotation.SuppressLint import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.FastOutLinearInEasing @@ -21,11 +22,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -39,6 +42,7 @@ import androidx.compose.ui.graphics.lerp import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp @@ -49,6 +53,7 @@ import hk.uwu.reareye.ui.components.config.BusinessExtraConfigManagerScreen import hk.uwu.reareye.ui.components.config.BusinessManagerScreen import hk.uwu.reareye.ui.components.config.CardManagerScreen import hk.uwu.reareye.ui.components.config.ConfigNodeRow +import hk.uwu.reareye.ui.components.config.CustomBoundsCompatManagerScreen import hk.uwu.reareye.ui.components.config.RearWallpaperManagerScreen import hk.uwu.reareye.ui.components.config.SceneRouteManagerScreen import hk.uwu.reareye.ui.config.ConfigCategory @@ -98,6 +103,7 @@ private sealed interface ConfigRoute { data object SceneRouteManager : ConfigRoute data object CardManager : ConfigRoute data object BusinessExtraManager : ConfigRoute + data object CustomBoundsCompatManager : ConfigRoute } private const val NAV_BAR_EXIT_DURATION_MS = 220L @@ -124,12 +130,16 @@ private fun ConfigRoute.isOverlayRoute(): Boolean { this is ConfigRoute.BusinessManager || this is ConfigRoute.SceneRouteManager || this is ConfigRoute.CardManager || - this is ConfigRoute.BusinessExtraManager + this is ConfigRoute.BusinessExtraManager || + this is ConfigRoute.CustomBoundsCompatManager } +@SuppressLint("LocalContextResourcesRead") @Composable fun ConfigScreen( bottomInnerPadding: Dp = 0.dp, + quickManagerTarget: ConfigType.ManagerType? = null, + onQuickManagerTargetHandled: () -> Unit = {}, onAppListModeChange: (Boolean) -> Unit = {}, onThemeModeChange: (Int) -> Unit = {}, onNavigationBarModeChange: (Int) -> Unit = {}, @@ -137,7 +147,33 @@ fun ConfigScreen( val context = LocalContext.current val prefsManager = remember { context.getPrefsManager() } - var routeStack by remember { mutableStateOf(listOf(ConfigRoute.Root)) } + fun findLyricsCategoryRoute(): ConfigRoute? { + return findConfigCategoryByTitleRes(REAREyeConfig, R.string.subcategory_lyrics)?.let( + ConfigRoute::Category + ) + } + + fun managerRoute(managerType: ConfigType.ManagerType?): ConfigRoute? { + return when (managerType) { + ConfigType.ManagerType.REAR_WALLPAPER -> ConfigRoute.RearWallpaperManager + ConfigType.ManagerType.BUSINESS -> ConfigRoute.BusinessManager + ConfigType.ManagerType.SCENE_ROUTE -> ConfigRoute.SceneRouteManager + ConfigType.ManagerType.CARD -> ConfigRoute.CardManager + ConfigType.ManagerType.BUSINESS_EXTRA -> ConfigRoute.BusinessExtraManager + ConfigType.ManagerType.BOUNDS -> ConfigRoute.CustomBoundsCompatManager + ConfigType.ManagerType.LYRICS -> findLyricsCategoryRoute() + null -> null + } + } + + var routeStack by remember { + mutableStateOf( + listOf( + ConfigRoute.Root, + managerRoute(quickManagerTarget) + ).filterNotNull() + ) + } val currentRoute = routeStack.last() val isOverlayMode = currentRoute.isOverlayRoute() val animatedRoute = remember(currentRoute, routeStack.size) { @@ -152,7 +188,10 @@ fun ConfigScreen( val routeScope = rememberCoroutineScope() val favoriteNodeIndex = remember(REAREyeConfig) { - buildFavoriteConfigNodeIndex(REAREyeConfig) + buildFavoriteConfigNodeIndex( + nodes = REAREyeConfig, + resolveResourceEntryName = context.resources::getResourceEntryName, + ) } val availableFavoriteNodeIds = remember(favoriteNodeIndex.entries) { favoriteNodeIndex.entries.mapTo(mutableSetOf()) { it.id } @@ -163,7 +202,9 @@ fun ConfigScreen( val initialFavoriteNodeIds = remember(storedFavoriteNodeIds, availableFavoriteNodeIds) { storedFavoriteNodeIds.filterTo(mutableSetOf()) { it in availableFavoriteNodeIds } } - var favoriteNodeIds by remember { mutableStateOf(initialFavoriteNodeIds.toSet()) } + var favoriteNodeIds by remember { + mutableStateOf(initialFavoriteNodeIds.toSet()) + } val favoriteNodes = remember(favoriteNodeIds, favoriteNodeIndex.entries) { favoriteNodeIndex.entries .filter { favoriteNodeIds.contains(it.id) } @@ -242,13 +283,31 @@ fun ConfigScreen( fun closeOverlayRoute() { routeScope.launch { - val newStack = routeStack.dropLast(1) - routeStack = newStack + routeStack = listOf(ConfigRoute.Root) delay(OVERLAY_ROUTE_EXIT_DURATION_MS) - if (!newStack.last().isOverlayRoute()) { - onAppListModeChange(false) - } + onAppListModeChange(false) + } + } + + fun openManagerRoute(managerType: ConfigType.ManagerType?) { + val route = managerRoute(managerType) + if (route != null) { + openOverlayRoute(route) + } + } + + fun openManagerItem(item: ConfigItem) { + openManagerRoute((item.type as? ConfigType.Manager)?.managerType) + } + + LaunchedEffect(quickManagerTarget) { + val managerType = quickManagerTarget ?: return@LaunchedEffect + val route = managerRoute(managerType) ?: return@LaunchedEffect + routeStack = listOf(ConfigRoute.Root, route) + if (route.isOverlayRoute()) { + onAppListModeChange(true) } + onQuickManagerTargetHandled() } Scaffold( @@ -324,31 +383,7 @@ fun ConfigScreen( onOpenAppList = { item -> openOverlayRoute(ConfigRoute.AppList(item)) }, - onOpenManager = { item -> - when ((item.type as? ConfigType.Manager)?.managerType) { - ConfigType.ManagerType.REAR_WALLPAPER -> { - openOverlayRoute(ConfigRoute.RearWallpaperManager) - } - - ConfigType.ManagerType.BUSINESS -> { - openOverlayRoute(ConfigRoute.BusinessManager) - } - - ConfigType.ManagerType.SCENE_ROUTE -> { - openOverlayRoute(ConfigRoute.SceneRouteManager) - } - - ConfigType.ManagerType.CARD -> { - openOverlayRoute(ConfigRoute.CardManager) - } - - ConfigType.ManagerType.BUSINESS_EXTRA -> { - openOverlayRoute(ConfigRoute.BusinessExtraManager) - } - - null -> Unit - } - }, + onOpenManager = { item -> openManagerItem(item) }, onPreferenceChanged = handlePreferenceChanged, showFavoriteCategoryEntry = true, favoriteNodeCount = favoriteNodes.size, @@ -379,31 +414,7 @@ fun ConfigScreen( onOpenAppList = { item -> openOverlayRoute(ConfigRoute.AppList(item)) }, - onOpenManager = { item -> - when ((item.type as? ConfigType.Manager)?.managerType) { - ConfigType.ManagerType.REAR_WALLPAPER -> { - openOverlayRoute(ConfigRoute.RearWallpaperManager) - } - - ConfigType.ManagerType.BUSINESS -> { - openOverlayRoute(ConfigRoute.BusinessManager) - } - - ConfigType.ManagerType.SCENE_ROUTE -> { - openOverlayRoute(ConfigRoute.SceneRouteManager) - } - - ConfigType.ManagerType.CARD -> { - openOverlayRoute(ConfigRoute.CardManager) - } - - ConfigType.ManagerType.BUSINESS_EXTRA -> { - openOverlayRoute(ConfigRoute.BusinessExtraManager) - } - - null -> Unit - } - }, + onOpenManager = { item -> openManagerItem(item) }, onPreferenceChanged = handlePreferenceChanged, favoriteNodeIds = favoriteNodeIds, resolveFavoriteNodeId = { node -> @@ -429,31 +440,7 @@ fun ConfigScreen( onOpenAppList = { item -> openOverlayRoute(ConfigRoute.AppList(item)) }, - onOpenManager = { item -> - when ((item.type as? ConfigType.Manager)?.managerType) { - ConfigType.ManagerType.REAR_WALLPAPER -> { - openOverlayRoute(ConfigRoute.RearWallpaperManager) - } - - ConfigType.ManagerType.BUSINESS -> { - openOverlayRoute(ConfigRoute.BusinessManager) - } - - ConfigType.ManagerType.SCENE_ROUTE -> { - openOverlayRoute(ConfigRoute.SceneRouteManager) - } - - ConfigType.ManagerType.CARD -> { - openOverlayRoute(ConfigRoute.CardManager) - } - - ConfigType.ManagerType.BUSINESS_EXTRA -> { - openOverlayRoute(ConfigRoute.BusinessExtraManager) - } - - null -> Unit - } - }, + onOpenManager = { item -> openManagerItem(item) }, onPreferenceChanged = handlePreferenceChanged, emptyStateRes = R.string.config_favorites_empty, favoriteNodeIds = favoriteNodeIds, @@ -496,11 +483,31 @@ fun ConfigScreen( prefsManager = prefsManager, onBack = { closeOverlayRoute() }, ) + + ConfigRoute.CustomBoundsCompatManager -> CustomBoundsCompatManagerScreen( + prefsManager = prefsManager, + onBack = { closeOverlayRoute() }, + ) } } } } +private fun findConfigCategoryByTitleRes( + nodes: List, + titleRes: Int, +): ConfigCategory? { + nodes.forEach { node -> + if (node is ConfigCategory) { + if (node.titleRes == titleRes) return node + findConfigCategoryByTitleRes(node.children, titleRes)?.let { return it } + } else if (node is ConfigGroup) { + findConfigCategoryByTitleRes(node.children, titleRes)?.let { return it } + } + } + return null +} + @Composable private fun ConfigNodeList( nodes: List, @@ -520,7 +527,13 @@ private fun ConfigNodeList( resolveFavoriteNodeId: (ConfigNode) -> String? = { null }, onToggleFavorite: (ConfigNode) -> Unit = {}, ) { + val listState = rememberLazyListState() + val isListScrolling by remember { + derivedStateOf { listState.isScrollInProgress } + } + LazyColumn( + state = listState, modifier = Modifier .fillMaxHeight() .scrollEndHaptic() @@ -596,6 +609,7 @@ private fun ConfigNodeList( ConfigNodeRowWithFavoriteMenu( node = child, prefsManager = prefsManager, + isListScrolling = isListScrolling, onOpenCategory = onOpenCategory, onOpenAppList = onOpenAppList, onOpenManager = onOpenManager, @@ -615,6 +629,7 @@ private fun ConfigNodeList( ConfigNodeRowWithFavoriteMenu( node = node, prefsManager = prefsManager, + isListScrolling = isListScrolling, onOpenCategory = onOpenCategory, onOpenAppList = onOpenAppList, onOpenManager = onOpenManager, @@ -633,6 +648,7 @@ private fun ConfigNodeList( private fun ConfigNodeRowWithFavoriteMenu( node: ConfigNode, prefsManager: PrefsManager, + isListScrolling: Boolean, onOpenCategory: (ConfigCategory) -> Unit, onOpenAppList: (ConfigItem) -> Unit, onOpenManager: (ConfigItem) -> Unit, @@ -650,6 +666,7 @@ private fun ConfigNodeRowWithFavoriteMenu( Box( modifier = Modifier.configNodeLongPress( enabled = canFavorite, + isScrolling = isListScrolling, onLongPress = { showFavoritePopup = true }, ) ) { @@ -745,16 +762,18 @@ private fun FavoritePopupIcon( private fun Modifier.configNodeLongPress( enabled: Boolean, + isScrolling: Boolean, onLongPress: () -> Unit, ): Modifier { - if (!enabled) return this + if (!enabled || isScrolling) return this - return this.pointerInput(onLongPress) { + return this.pointerInput(onLongPress, false) { awaitEachGesture { val down = awaitFirstDown( requireUnconsumed = false, pass = PointerEventPass.Initial, ) + if (isScrolling) return@awaitEachGesture val longPressTriggered = withTimeoutOrNull( timeMillis = viewConfiguration.longPressTimeoutMillis, ) { @@ -762,7 +781,7 @@ private fun Modifier.configNodeLongPress( val event = awaitPointerEvent(PointerEventPass.Initial) val change = event.changes.firstOrNull { it.id == down.id } ?: return@withTimeoutOrNull false - if (!change.pressed || change.isConsumed) { + if (!change.pressed || change.isConsumed || change.positionChanged() || isScrolling) { return@withTimeoutOrNull false } } @@ -788,14 +807,17 @@ private fun Modifier.configNodeLongPress( } } -private fun buildFavoriteConfigNodeIndex(nodes: List): FavoriteConfigNodeIndex { +private fun buildFavoriteConfigNodeIndex( + nodes: List, + resolveResourceEntryName: (Int) -> String, +): FavoriteConfigNodeIndex { val entries = mutableListOf() val nodeIdLookup = IdentityHashMap() val idCounters = mutableMapOf() - fun nextNodeId(baseId: String): String { - val current = (idCounters[baseId] ?: 0) + 1 - idCounters[baseId] = current + fun nextNodeId(counters: MutableMap, baseId: String): String { + val current = (counters[baseId] ?: 0) + 1 + counters[baseId] = current return if (current == 1) { baseId } else { @@ -803,16 +825,21 @@ private fun buildFavoriteConfigNodeIndex(nodes: List): FavoriteConfi } } - fun walk(currentNodes: List, categoryPath: List) { + fun registerNode(node: ConfigNode, nodeId: String) { + entries += FavoriteConfigNodeEntry(id = nodeId, node = node) + nodeIdLookup[node] = nodeId + } + + fun walk(currentNodes: List, categoryPath: List) { currentNodes.forEach { node -> when (node) { is ConfigCategory -> { - val baseNodeId = node.key?.let { "category:key:$it" } - ?: "category:path:${(categoryPath + node.titleRes).joinToString("/")}" - val nodeId = nextNodeId(baseNodeId) - entries += FavoriteConfigNodeEntry(id = nodeId, node = node) - nodeIdLookup[node] = nodeId - walk(node.children, categoryPath + node.titleRes) + val pathToken = node.key?.let { "key:$it" } + ?: "title:${resolveResourceEntryName(node.titleRes)}" + val baseId = "category:path:${(categoryPath + pathToken).joinToString("/")}" + val nodeId = nextNodeId(idCounters, baseId) + registerNode(node, nodeId) + walk(node.children, categoryPath + pathToken) } is ConfigGroup -> { @@ -820,14 +847,16 @@ private fun buildFavoriteConfigNodeIndex(nodes: List): FavoriteConfi } is ConfigItem -> { - val nodeId = nextNodeId("item:key:${node.key}") - entries += FavoriteConfigNodeEntry(id = nodeId, node = node) - nodeIdLookup[node] = nodeId + val nodeId = nextNodeId(idCounters, "item:key:${node.key}") + registerNode(node, nodeId) } } } } walk(nodes, emptyList()) - return FavoriteConfigNodeIndex(entries = entries, nodeIdLookup = nodeIdLookup) + return FavoriteConfigNodeIndex( + entries = entries, + nodeIdLookup = nodeIdLookup, + ) } diff --git a/app/src/main/java/hk/uwu/reareye/ui/screen/HomeScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/screen/HomeScreen.kt index 17498d2..8bb1125 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/screen/HomeScreen.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/screen/HomeScreen.kt @@ -275,6 +275,7 @@ fun HomeScreen(bottomInnerPadding: Dp = 0.dp) { EasterEggType.HUAWEI_ULTIMATE_DESIGN -> "ULTIMATE DESIGN" else -> AppProperties.BUILD_CHANNEL } + val versionCodename = AppProperties.PROJECT_APP_VERSION_CODENAME Scaffold( topBar = { @@ -458,6 +459,7 @@ fun HomeScreen(bottomInnerPadding: Dp = 0.dp) { ModuleInfoCard( activated = isActivated, moduleVersion = moduleVersion, + versionCodename = versionCodename, releaseChannel = releaseChannel, easterEggType = easterEggType ) @@ -818,6 +820,7 @@ private fun UpdateWarningCard(currentHash: String, latestHash: String, useMonetC private fun ModuleInfoCard( activated: Boolean, moduleVersion: String, + versionCodename: String, releaseChannel: String, easterEggType: EasterEggType ) { @@ -854,6 +857,11 @@ private fun ModuleInfoCard( value = moduleVersion, ) Spacer(modifier = Modifier.height(12.dp)) + InfoLine( + title = androidx.compose.ui.res.stringResource(R.string.version_codename_label), + value = versionCodename, + ) + Spacer(modifier = Modifier.height(12.dp)) InfoLine( title = androidx.compose.ui.res.stringResource(R.string.home_status_channel), value = releaseChannel, @@ -889,12 +897,14 @@ private fun UpdateInfoCard(currentHash: String, latestHash: String?, checking: B } @Composable -private fun InfoLine(title: String, value: String) { - Text(text = title, style = MiuixTheme.textStyles.headline1) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = value, - style = MiuixTheme.textStyles.body2, - color = MiuixTheme.colorScheme.onSurfaceVariantSummary, - ) +fun InfoLine(title: String, value: String, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + Text(text = title, style = MiuixTheme.textStyles.headline1) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = value, + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } } diff --git a/app/src/main/java/hk/uwu/reareye/ui/screen/RearStoreScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/screen/RearStoreScreen.kt index d44a70a..fe78e55 100644 --- a/app/src/main/java/hk/uwu/reareye/ui/screen/RearStoreScreen.kt +++ b/app/src/main/java/hk/uwu/reareye/ui/screen/RearStoreScreen.kt @@ -4,8 +4,10 @@ import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.graphics.BitmapFactory +import android.util.Log import android.view.View import android.view.ViewGroup +import android.webkit.WebChromeClient import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebSettings @@ -40,6 +42,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars @@ -82,6 +85,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -96,12 +100,14 @@ import hk.uwu.reareye.repository.rearstore.RearStoreInstallProgressStage import hk.uwu.reareye.repository.rearstore.RearStoreInstalledWidget import hk.uwu.reareye.repository.rearstore.RearStoreListItem import hk.uwu.reareye.repository.rearstore.RearStorePreparedInstallAsset +import hk.uwu.reareye.repository.rearstore.RearStoreQuickInstallResult import hk.uwu.reareye.repository.rearstore.RearStoreRelease import hk.uwu.reareye.repository.rearstore.RearStoreReleaseAsset import hk.uwu.reareye.repository.rearstore.RearStoreRepository import hk.uwu.reareye.repository.rearstore.RearStoreWidgetDetail import hk.uwu.reareye.repository.rearstore.RearStoreWidgetInfoType import hk.uwu.reareye.repository.rearstore.RearStoreWidgetMetadataType +import hk.uwu.reareye.repository.rearstore.evaluateRequirements import hk.uwu.reareye.repository.rearstore.resolvedType import hk.uwu.reareye.repository.rearstore.supportsModuleVersion import hk.uwu.reareye.repository.rearwallpaper.RearWallpaperMetadataOptions @@ -116,7 +122,6 @@ import hk.uwu.reareye.ui.components.card.ModuleStyleDeleteAction import hk.uwu.reareye.ui.components.card.SuperCard import hk.uwu.reareye.ui.components.config.WallpaperMetadataFields import hk.uwu.reareye.ui.components.motion.ArtRevealItem -import hk.uwu.reareye.ui.components.motion.ArtStaggeredReveal import hk.uwu.reareye.ui.components.rememberRearAccentBadgePalette import hk.uwu.reareye.ui.components.webview.ScrollWebView import hk.uwu.reareye.ui.config.ConfigKeys @@ -139,10 +144,10 @@ import top.yukonga.miuix.kmp.basic.Button import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.CardDefaults -import top.yukonga.miuix.kmp.basic.CircularProgressIndicator import top.yukonga.miuix.kmp.basic.HorizontalDivider import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator import top.yukonga.miuix.kmp.basic.ListPopupColumn import top.yukonga.miuix.kmp.basic.ListPopupDefaults import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior @@ -214,6 +219,11 @@ private data class RearStorePendingInstallConflict( val conflict: RearStoreInstallConflict, ) +private data class RearStorePostInstallBrowserState( + val title: String, + val url: String, +) + private data class RearStorePendingWallpaperInstall( val preparedAsset: RearStorePreparedInstallAsset, ) @@ -344,9 +354,17 @@ private data class RearStoreInstallInfo( val hasScene: Boolean, val scenePackageName: String?, val hasWallpaper: Boolean, + val requirementsSatisfied: Boolean, + val appListPermissionGranted: Boolean, + val missingPackages: List, + val failedConfigReasons: Map, ) -private fun defaultInstallInfo(detail: RearStoreWidgetDetail): RearStoreInstallInfo { +private fun defaultInstallInfo( + context: Context, + prefsManager: hk.uwu.reareye.ui.config.PrefsManager, + detail: RearStoreWidgetDetail, +): RearStoreInstallInfo { val widgetInfoType = detail.widgetInfo.resolvedType() val metadataType = detail.displayMetadataType() val businessSetupId = detail.widgetInfo?.businessSetup?.id.normalizedOrNull() @@ -356,6 +374,7 @@ private fun defaultInstallInfo(detail: RearStoreWidgetDetail): RearStoreInstallI val scenePackageName = sceneSetup?.packageName.normalizedOrNull() val hasCard = widgetInfoType == RearStoreWidgetInfoType.WIDGET && detail.widgetInfo?.cardSetup != null + val requirementsResult = detail.widgetInfo.evaluateRequirements(context, prefsManager) return RearStoreInstallInfo( widgetInfoType = widgetInfoType, hasComponent = widgetInfoType == RearStoreWidgetInfoType.WIDGET, @@ -368,6 +387,10 @@ private fun defaultInstallInfo(detail: RearStoreWidgetDetail): RearStoreInstallI scenePackageName = scenePackageName, hasWallpaper = widgetInfoType == RearStoreWidgetInfoType.WALLPAPER || metadataType == RearStoreWidgetMetadataType.WALLPAPER, + requirementsSatisfied = requirementsResult.satisfied, + appListPermissionGranted = requirementsResult.appListPermissionGranted, + missingPackages = requirementsResult.missingPackages, + failedConfigReasons = requirementsResult.failedConfigReasons, ) } @@ -461,6 +484,7 @@ private fun RearStoreWidgetDetail.displayMetadataType(): RearStoreWidgetMetadata private fun resolveInstallBlockMessage( context: Context, + prefsManager: hk.uwu.reareye.ui.config.PrefsManager, detail: RearStoreWidgetDetail, ): String? { val widgetInfoType = detail.widgetInfo.resolvedType() @@ -478,6 +502,28 @@ private fun resolveInstallBlockMessage( ) { return context.getString(R.string.rear_store_install_missing_business_setup) } + val requirementsResult = detail.widgetInfo.evaluateRequirements(context, prefsManager) + if (!requirementsResult.satisfied) { + return buildString { + appendLine(context.getString(R.string.rear_store_install_requirements_not_met)) + if (!requirementsResult.appListPermissionGranted && requirementsResult.missingPackages.isNotEmpty()) { + appendLine("- ${context.getString(R.string.rear_store_install_requirements_packages_permission)}") + } + requirementsResult.missingPackages.forEach { pkg -> + appendLine( + "- ${ + context.getString( + R.string.rear_store_install_requirements_missing_package_item, + pkg + ) + }" + ) + } + requirementsResult.failedConfigReasons.values.forEach { reason -> + appendLine("- $reason") + } + }.trim() + } return null } @@ -553,6 +599,70 @@ private fun parseIsoEpochMillis(value: String?): Long { return Long.MIN_VALUE } +private fun buildPostInstallUri( + detail: RearStoreWidgetDetail, + installResult: RearStoreQuickInstallResult, +): String? { + val template = detail.widgetInfo?.postInstall?.uri.normalizedOrNull() ?: return null + return template + .replace("{id}", detail.widgetId) + .replace("{business}", installResult.businessConfigId ?: "{business}") + .replace("{card}", installResult.cardId ?: "{card}") +} + +private fun launchPostInstallUri( + context: Context, + detail: RearStoreWidgetDetail, + installResult: RearStoreQuickInstallResult, + onOpenBrowser: (RearStorePostInstallBrowserState) -> Unit, +) { + val uriString = buildPostInstallUri(detail, installResult) ?: return + val uri = runCatching { uriString.toUri() }.getOrNull() ?: return + val scheme = uri.scheme?.lowercase(Locale.ROOT).orEmpty() + if (scheme == "content") { + runCatching { + context.applicationContext.contentResolver.query(uri, null, null, null, null) + ?.use { cursor -> + val columns = (0 until cursor.columnCount).joinToString(", ") { index -> + cursor.getColumnName(index) + } + val rowCount = if (cursor.moveToFirst()) 1 else 0 + val debugValues = if (rowCount > 0) { + (0 until cursor.columnCount).joinToString(", ") { index -> + val value = runCatching { cursor.getString(index) }.getOrNull() + "${cursor.getColumnName(index)}=${value ?: ""}" + } + } else { + "" + } + Log.d( + "RearStorePostInstall", + "query content uri=$uri columns=[$columns] rows=$rowCount values=[$debugValues]", + ) + } ?: Log.d("RearStorePostInstall", "query content uri=$uri result=") + }.onFailure { error -> + Log.e("RearStorePostInstall", "query content uri=$uri failed", error) + } + return + } + if (scheme == "http" || scheme == "https") { + onOpenBrowser( + RearStorePostInstallBrowserState( + title = detail.name.normalizedOrNull() ?: detail.widgetId, + url = uriString, + ) + ) + return + } + runCatching { + context.startActivity( + Intent(Intent.ACTION_VIEW, uri).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + ) + } +} + private fun RearStoreListItem.matchesLocalQuery(query: String): Boolean { val tokens = query.trim() .split(Regex("\\s+")) @@ -965,19 +1075,13 @@ private fun RearStoreRootContent( RearStoreMessageCard(text = stringResource(R.string.rear_store_search_empty)) } } else { - itemsIndexed(items, key = { _, item -> item.id }) { index, item -> - ArtStaggeredReveal( - visible = true, - revealKey = item.id + searchQuery, - delayMillis = (24 + index * 18).coerceAtMost(160), - ) { - RearStoreListCard( - item = item, - installedWidget = installedWidgets[item.id], - updateAvailable = item.hasAvailableUpdate(installedWidgets), - onClick = { onOpenDetail(item.id) }, - ) - } + itemsIndexed(items, key = { _, item -> item.id }) { _, item -> + RearStoreListCard( + item = item, + installedWidget = installedWidgets[item.id], + updateAvailable = item.hasAvailableUpdate(installedWidgets), + onClick = { onOpenDetail(item.id) }, + ) } } } @@ -1021,12 +1125,22 @@ private fun RearStoreDetailContent( var wallpaperMetadataDraft by remember(widgetId) { mutableStateOf(null) } + var blockedInstallMessage by remember(widgetId) { mutableStateOf(null) } + var postInstallBrowserState by remember(widgetId) { + mutableStateOf(null) + } suspend fun reloadDetail() { loading = true loadFailed = false detail = withContext(Dispatchers.IO) { - runCatching { RearStoreRepository.loadWidgetDetail(prefsManager, widgetId) }.getOrNull() + runCatching { + RearStoreRepository.loadWidgetDetail( + prefsManager = prefsManager, + widgetId = widgetId, + widgetInfoVersion = installedWidget?.releaseTag, + ) + }.getOrNull() } readmeLoading = false readmeLoaded = detail?.readme != null @@ -1034,7 +1148,7 @@ private fun RearStoreDetailContent( loading = false } - LaunchedEffect(widgetId) { + LaunchedEffect(widgetId, installedWidget?.releaseTag) { reloadDetail() } @@ -1058,14 +1172,28 @@ private fun RearStoreDetailContent( return widgetId + ":" + release.tagName + ":" + asset.name } + suspend fun loadInstallDetail(release: RearStoreRelease): RearStoreWidgetDetail? { + val widgetDetail = detail ?: return null + return withContext(Dispatchers.IO) { + runCatching { + RearStoreRepository.loadWidgetDetailForRelease( + prefsManager = prefsManager, + detail = widgetDetail, + releaseTag = release.tagName, + ) + }.getOrDefault(widgetDetail) + } + } + suspend fun performInstall( release: RearStoreRelease, asset: RearStoreReleaseAsset, forceOverwrite: Boolean = false, preparedAsset: RearStorePreparedInstallAsset? = null, wallpaperMetadataOptions: RearWallpaperMetadataOptions? = null, + installDetail: RearStoreWidgetDetail? = null, ) { - val widgetDetail = detail ?: return + val widgetDetail = installDetail ?: loadInstallDetail(release) ?: return val assetKey = installAssetKey(release, asset) activeInstall = RearStoreActiveInstall(assetKey = assetKey) val result = runCatching { @@ -1104,6 +1232,12 @@ private fun RearStoreDetailContent( delay(360) activeInstall = null onInstalled() + launchPostInstallUri( + context = context, + detail = widgetDetail, + installResult = installResult, + onOpenBrowser = { postInstallBrowserState = it }, + ) Toast.makeText( context, context.getString( @@ -1133,7 +1267,7 @@ private fun RearStoreDetailContent( } suspend fun prepareWallpaperInstall(release: RearStoreRelease, asset: RearStoreReleaseAsset) { - val widgetDetail = detail ?: return + val widgetDetail = loadInstallDetail(release) ?: return val assetKey = installAssetKey(release, asset) activeInstall = RearStoreActiveInstall(assetKey = assetKey) val preparedResult = runCatching { @@ -1181,6 +1315,7 @@ private fun RearStoreDetailContent( release = release, asset = asset, preparedAsset = preparedAsset, + installDetail = widgetDetail, ) } @@ -1189,10 +1324,10 @@ private fun RearStoreDetailContent( asset: RearStoreReleaseAsset, forceOverwrite: Boolean = false, ) { - val widgetDetail = detail ?: return - val blockedMessage = resolveInstallBlockMessage(context, widgetDetail) + val widgetDetail = loadInstallDetail(release) ?: return + val blockedMessage = resolveInstallBlockMessage(context, prefsManager, widgetDetail) if (blockedMessage != null) { - Toast.makeText(context, blockedMessage, Toast.LENGTH_SHORT).show() + blockedInstallMessage = blockedMessage return } val conflict = withContext(Dispatchers.IO) { @@ -1209,7 +1344,12 @@ private fun RearStoreDetailContent( if (widgetDetail.widgetInfo.resolvedType() == RearStoreWidgetInfoType.WALLPAPER) { prepareWallpaperInstall(release, asset) } else { - performInstall(release, asset, forceOverwrite = forceOverwrite) + performInstall( + release, + asset, + forceOverwrite = forceOverwrite, + installDetail = widgetDetail + ) } } @@ -1310,7 +1450,7 @@ private fun RearStoreDetailContent( .padding(paddingValues), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator() + InfiniteProgressIndicator() } return@Scaffold } @@ -1392,6 +1532,7 @@ private fun RearStoreDetailContent( selectedTab = selectedTab, widgetDetail = widgetDetail, installedWidget = installedWidget, + prefsManager = prefsManager, visibleReleases = visibleReleases, showAllReleases = showAllReleases, readmeLoading = readmeLoading, @@ -1445,6 +1586,56 @@ private fun RearStoreDetailContent( } } + OverlayDialog( + show = blockedInstallMessage != null, + title = stringResource(R.string.rear_store_install_blocked_title), + onDismissRequest = { blockedInstallMessage = null }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Card(modifier = Modifier.fillMaxWidth()) { + RearStoreDialogMessageCard(message = blockedInstallMessage.orEmpty()) + } + Button( + onClick = { blockedInstallMessage = null }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.rear_widget_cancel)) + } + } + } + + val postInstallBrowser = postInstallBrowserState + OverlayDialog( + show = postInstallBrowser != null, + title = postInstallBrowser?.title + ?: stringResource(R.string.rear_store_postinstall_browser_title), + onDismissRequest = { postInstallBrowserState = null }, + outsideMargin = DpSize(width = 10.dp, height = 10.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Card(modifier = Modifier.fillMaxWidth()) { + RearStorePostInstallBrowser( + url = postInstallBrowser?.url.orEmpty(), + ) + } + Button( + onClick = { + val targetUrl = postInstallBrowser?.url ?: return@Button + context.startActivity(Intent(Intent.ACTION_VIEW, targetUrl.toUri())) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.rear_store_postinstall_open_in_browser)) + } + Button( + onClick = { postInstallBrowserState = null }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.rear_widget_cancel)) + } + } + } + val conflictDialog = installConflict OverlayDialog( show = conflictDialog != null, @@ -1594,6 +1785,7 @@ private fun RearStoreDetailTabContent( selectedTab: RearStoreDetailTab, widgetDetail: RearStoreWidgetDetail, installedWidget: RearStoreInstalledWidget?, + prefsManager: hk.uwu.reareye.ui.config.PrefsManager, visibleReleases: List, showAllReleases: Boolean, readmeLoading: Boolean, @@ -1661,7 +1853,10 @@ private fun RearStoreDetailTabContent( onClick = onRequestUninstall, ) } - RearStoreInstallInfoCard(detail = widgetDetail) + RearStoreInstallInfoCard( + detail = widgetDetail, + prefsManager = prefsManager, + ) RearStoreRepositoryCard(detail = widgetDetail) RearStoreAuthorCard( author = widgetDetail.author, @@ -2237,8 +2432,14 @@ private fun rememberInstallInfoBadgePalette(group: RearStoreInstallInfoBadgeGrou } @Composable -private fun RearStoreInstallInfoCard(detail: RearStoreWidgetDetail) { - val installInfo = defaultInstallInfo(detail) +private fun RearStoreInstallInfoCard( + detail: RearStoreWidgetDetail, + prefsManager: hk.uwu.reareye.ui.config.PrefsManager, +) { + val context = LocalContext.current + val installInfo = remember(detail, prefsManager) { + defaultInstallInfo(context, prefsManager, detail) + } val componentPalette = rememberInstallInfoBadgePalette(RearStoreInstallInfoBadgeGroup.COMPONENT) val cardPalette = rememberInstallInfoBadgePalette(RearStoreInstallInfoBadgeGroup.CARD) val notificationPalette = @@ -2270,7 +2471,7 @@ private fun RearStoreInstallInfoCard(detail: RearStoreWidgetDetail) { palette = cardPalette, ) ) - installInfo.cardPackageName?.takeIf { installInfo.hasCard }?.let { + installInfo.cardPackageName?.let { add( RearBadgeItem( text = stringResource(R.string.rear_store_install_badge_card_package, it), @@ -2314,6 +2515,37 @@ private fun RearStoreInstallInfoCard(detail: RearStoreWidgetDetail) { ) ) } + if (!installInfo.appListPermissionGranted && installInfo.missingPackages.isNotEmpty()) { + add( + RearBadgeItem( + text = stringResource(R.string.rear_store_install_requirements_packages_permission), + palette = warningPalette, + ) + ) + } + if (installInfo.missingPackages.isNotEmpty() && installInfo.appListPermissionGranted) { + installInfo.missingPackages.forEach { pkg -> + add( + RearBadgeItem( + text = stringResource( + R.string.rear_store_install_requirements_missing_package_item, + pkg + ), + palette = warningPalette, + ) + ) + } + } + if (installInfo.failedConfigReasons.isNotEmpty()) { + installInfo.failedConfigReasons.values.forEach { reason -> + add( + RearBadgeItem( + text = reason, + palette = warningPalette, + ) + ) + } + } } Card(modifier = Modifier.fillMaxWidth()) { Column( @@ -2712,6 +2944,87 @@ private fun WebView.publishMarkdownContentHeight(onContentHeightChanged: (Int) - } } +@Composable +private fun RearStorePostInstallBrowser(url: String) { + val context = LocalContext.current + val prefsManager = remember(context) { context.getPrefsManager() } + val webViewHardwareAccelerationEnabled = prefsManager.getBoolean( + ConfigKeys.MODULE_STORE_WEBVIEW_HARDWARE_ACCELERATION, + true, + ) + val colorScheme = MiuixTheme.colorScheme + val backgroundColor = if (webViewHardwareAccelerationEnabled) { + Color.Transparent + } else { + colorScheme.surface + } + val nestedScrollInterop = rememberNestedScrollInteropConnection() + + AndroidView( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 320.dp, max = 560.dp) + .nestedScroll(nestedScrollInterop), + factory = { viewContext -> + createRearStoreBrowserWebView( + context = viewContext, + hardwareAccelerationEnabled = webViewHardwareAccelerationEnabled, + backgroundColor = backgroundColor.toArgb(), + ).apply { + loadUrl(url) + } + }, + update = { webView -> + webView.setLayerType( + if (webViewHardwareAccelerationEnabled) View.LAYER_TYPE_NONE else View.LAYER_TYPE_SOFTWARE, + null, + ) + if (webView.url != url) { + webView.loadUrl(url) + } + }, + ) +} + +@SuppressLint("SetJavaScriptEnabled") +private fun createRearStoreBrowserWebView( + context: Context, + hardwareAccelerationEnabled: Boolean, + backgroundColor: Int, +): WebView { + return ScrollWebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + setLayerType( + if (hardwareAccelerationEnabled) View.LAYER_TYPE_NONE else View.LAYER_TYPE_SOFTWARE, + null, + ) + setBackgroundColor(backgroundColor) + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + builtInZoomControls = false + displayZoomControls = false + mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + allowContentAccess = true + allowFileAccess = false + setSupportMultipleWindows(false) + userAgentString = "$userAgentString REAREyeStoreWebView" + } + webChromeClient = WebChromeClient() + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean { + return false + } + } + } +} + @Composable private fun RearStoreLoadingCard() { Card(modifier = Modifier.fillMaxWidth()) { @@ -2722,7 +3035,7 @@ private fun RearStoreLoadingCard() { horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator() + InfiniteProgressIndicator() Text( text = stringResource(R.string.rear_widget_loading_data), modifier = Modifier.padding(start = 10.dp), diff --git a/app/src/main/java/hk/uwu/reareye/utils/PageUtils.kt b/app/src/main/java/hk/uwu/reareye/utils/PageUtils.kt index e2322dd..3c32e0c 100644 --- a/app/src/main/java/hk/uwu/reareye/utils/PageUtils.kt +++ b/app/src/main/java/hk/uwu/reareye/utils/PageUtils.kt @@ -1,45 +1,22 @@ -// Copyright 2025, compose-miuix-ui contributors -// SPDX-License-Identifier: Apache-2.0 - -package utils +package hk.uwu.reareye.utils import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.navigationBars import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import top.yukonga.miuix.kmp.basic.ScrollBehavior -import top.yukonga.miuix.kmp.basic.SmallTopAppBar -import top.yukonga.miuix.kmp.basic.TopAppBar import top.yukonga.miuix.kmp.blur.BlendColorEntry import top.yukonga.miuix.kmp.blur.BlurColors import top.yukonga.miuix.kmp.blur.LayerBackdrop -import top.yukonga.miuix.kmp.blur.isRenderEffectSupported -import top.yukonga.miuix.kmp.blur.rememberLayerBackdrop import top.yukonga.miuix.kmp.blur.textureBlur import top.yukonga.miuix.kmp.theme.MiuixTheme -import top.yukonga.miuix.kmp.utils.overScrollVertical -import top.yukonga.miuix.kmp.utils.scrollEndHaptic - -fun Modifier.pageScrollModifiers( - enableScrollEndHaptic: Boolean, - showTopAppBar: Boolean, - topAppBarScrollBehavior: ScrollBehavior, -): Modifier = this - .then(if (enableScrollEndHaptic) Modifier.scrollEndHaptic() else Modifier) - .overScrollVertical() - .then(if (showTopAppBar) Modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) else Modifier) - .fillMaxHeight() @Composable fun pageContentPadding( @@ -52,7 +29,8 @@ fun pageContentPadding( ): PaddingValues { val topPadding = innerPadding.calculateTopPadding() + extraTop val bottomPadding = if (isWideScreen) { - WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + outerPadding.calculateBottomPadding() + WindowInsets.navigationBars.asPaddingValues() + .calculateBottomPadding() + outerPadding.calculateBottomPadding() } else { outerPadding.calculateBottomPadding() } @@ -66,59 +44,20 @@ fun pageContentPadding( } } -@Composable -fun AdaptiveTopAppBar( - title: String, - showTopAppBar: Boolean, - isWideScreen: Boolean, - scrollBehavior: ScrollBehavior, - subtitle: String = "", - color: Color = MiuixTheme.colorScheme.surface, - navigationIcon: @Composable () -> Unit = {}, - actions: @Composable RowScope.() -> Unit = {}, - bottomContent: @Composable () -> Unit = {}, -) { - if (showTopAppBar) { - if (isWideScreen) { - SmallTopAppBar( - title = title, - subtitle = subtitle, - color = color, - scrollBehavior = scrollBehavior, - defaultWindowInsetsPadding = false, - navigationIcon = navigationIcon, - actions = actions, - bottomContent = bottomContent, - ) - } else { - TopAppBar( - title = title, - subtitle = subtitle, - color = color, - scrollBehavior = scrollBehavior, - navigationIcon = navigationIcon, - actions = actions, - bottomContent = bottomContent, - ) - } - } -} - @Composable fun BlurredBar( backdrop: LayerBackdrop?, - blurEnabled: Boolean, content: @Composable () -> Unit, ) { Box( - modifier = if (blurEnabled && backdrop != null) { + modifier = if (backdrop != null) { Modifier.textureBlur( backdrop = backdrop, shape = RectangleShape, - blurRadius = 25f, + blurRadius = 25f * LocalDensity.current.density, colors = BlurColors( blendColors = listOf( - BlendColorEntry(color = MiuixTheme.colorScheme.surface.copy(0.8f)), + BlendColorEntry(color = MiuixTheme.colorScheme.surface.copy(0.87f)), ), ), ) diff --git a/app/src/main/java/hk/uwu/reareye/utils/blend/BlendTokenConfig.kt b/app/src/main/java/hk/uwu/reareye/utils/blend/BlendTokenConfig.kt deleted file mode 100644 index 01ccf96..0000000 --- a/app/src/main/java/hk/uwu/reareye/utils/blend/BlendTokenConfig.kt +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright 2026, compose-miuix-ui contributors -// SPDX-License-Identifier: Apache-2.0 - -package hk.uwu.reareye.utils.blend - -import androidx.compose.ui.graphics.Color -import top.yukonga.miuix.kmp.blur.BlendColorEntry -import top.yukonga.miuix.kmp.blur.BlurBlendMode - -object BlendTokenConfig { - - /** - * 定义深色主题下的各种强度的混合颜色配置。 - */ - object DarkColors { - val DEFAULT = listOf( - BlendColorEntry(Color(0xE6A1A1A1), BlurBlendMode.Overlay), - BlendColorEntry(Color(0x4DE6E6E6), BlurBlendMode.HardLight), - ) - - val EXTRA_HEAVY = listOf( - BlendColorEntry(Color(0xE6A1A1A1), BlurBlendMode.ColorBurn), - BlendColorEntry(Color(0x4DE6E6E6), BlurBlendMode.SrcOver), - BlendColorEntry(Color(0x11B0B0B0), BlurBlendMode.SrcOver), // 假设 0xB0B0B0 是对应的颜色 - ) - - val HEAVY = listOf( - BlendColorEntry(Color(0x80121212), BlurBlendMode.ColorBurn), // 假设 0x121212 是对应的颜色 - BlendColorEntry(Color(0x40555555), BlurBlendMode.SrcOver), // 假设 0x555555 是对应的颜色 - ) - - val LIGHT = listOf( - BlendColorEntry(Color(0x61616161), BlurBlendMode.ColorBurn), // 假设 0x61616161 是对应的颜色 - BlendColorEntry(Color(0x4D4D4D4D), BlurBlendMode.SrcOver), // 假设 0x4D4D4D4D 是对应的颜色 - ) - - val EXTRA_LIGHT = listOf( - BlendColorEntry(Color(0x4D4D4D4D), BlurBlendMode.ColorBurn), // 假设 0x4D4D4D4D 是对应的颜色 - BlendColorEntry(Color(0x33333333), BlurBlendMode.SrcOver), // 假设 0x33333333 是对应的颜色 - ) - } - - /** - * 定义浅色主题下的各种强度的混合颜色配置。 - */ - object LightColors { - val DEFAULT = listOf( - BlendColorEntry(Color(0x701A1A1A), BlurBlendMode.ColorDodge), // 假设 0x1A1A1A 是对应的颜色 - BlendColorEntry(Color(0x5D5D5D5D), BlurBlendMode.SrcOver), // 假设 0x5D5D5D5D 是对应的颜色 - ) - - val EXTRA_HEAVY = listOf( - BlendColorEntry(Color(0x701A1A1A), BlurBlendMode.ColorDodge), // 假设 0x1A1A1A 是对应的颜色 - BlendColorEntry(Color(0x60606060), BlurBlendMode.SrcOver), // 假设 0x60606060 是对应的颜色 - ) - - val HEAVY = listOf( - BlendColorEntry(Color(0x5A5A5A5A), BlurBlendMode.ColorDodge), // 假设 0x5A5A5A5A 是对应的颜色 - BlendColorEntry(Color(0x37373737), BlurBlendMode.SrcOver), // 假设 0x37373737 是对应的颜色 - ) - - val LIGHT = listOf( - BlendColorEntry(Color(0x7A7A7A7A), BlurBlendMode.ColorDodge), // 假设 0x7A7A7A7A 是对应的颜色 - BlendColorEntry(Color(0x40404040), BlurBlendMode.SrcOver), // 假设 0x40404040 是对应的颜色 - ) - - val EXTRA_LIGHT = listOf( - BlendColorEntry(Color(0x80808080), BlurBlendMode.ColorDodge), // 假设 0x80808080 是对应的颜色 - BlendColorEntry(Color(0x27272727), BlurBlendMode.SrcOver), // 假设 0x27272727 是对应的颜色 - ) - } - - /** - * 定义深色主题下的混合模式。 - * 注意:这里的 BlendMode 可能也需要根据 Miuix KMP 的 API 进行调整。 - * 例如,如果 Miuix 使用的是枚举而非 Int,这里应相应改变。 - */ - object DarkModes { - val DEFAULT = listOf(BlurBlendMode.ColorDodge, BlurBlendMode.LinearLight, BlurBlendMode.LinearLight) - } - - object LightModes { - val DEFAULT = listOf(BlurBlendMode.ColorBurn, BlurBlendMode.LinearLight) - } - - /** - * 定义效果强度。 - */ - object Effects { - const val DEFAULT = 66 - const val EXTRA_THIN = 30 - const val HEAVY = 74 - const val THIN = 52 - } -} diff --git a/app/src/main/java/hk/uwu/reareye/utils/blend/BlurExt.kt b/app/src/main/java/hk/uwu/reareye/utils/blend/BlurExt.kt new file mode 100644 index 0000000..7df3922 --- /dev/null +++ b/app/src/main/java/hk/uwu/reareye/utils/blend/BlurExt.kt @@ -0,0 +1,17 @@ +package hk.uwu.reareye.utils.blend + +import androidx.compose.runtime.Composable +import top.yukonga.miuix.kmp.blur.LayerBackdrop +import top.yukonga.miuix.kmp.blur.isRenderEffectSupported +import top.yukonga.miuix.kmp.blur.rememberLayerBackdrop +import top.yukonga.miuix.kmp.theme.MiuixTheme + +@Composable +fun rememberBlurBackdrop(enableBlur: Boolean): LayerBackdrop? { + if (!enableBlur || !isRenderEffectSupported()) return null + val surfaceColor = MiuixTheme.colorScheme.surface + return rememberLayerBackdrop { + drawRect(surfaceColor) + drawContent() + } +} diff --git a/app/src/main/java/hk/uwu/reareye/utils/effect/FrameTimeSeconds.kt b/app/src/main/java/hk/uwu/reareye/utils/effect/FrameTimeSeconds.kt index 2040e37..f7991c2 100644 --- a/app/src/main/java/hk/uwu/reareye/utils/effect/FrameTimeSeconds.kt +++ b/app/src/main/java/hk/uwu/reareye/utils/effect/FrameTimeSeconds.kt @@ -31,7 +31,7 @@ fun rememberFrameTimeSeconds( time = startOffset + - (now - start) / 1_000_000_000f + (now - start) / 1_000_000_000f } } diff --git a/app/src/main/java/hk/uwu/reareye/utils/other/AboutLibrariesTools.kt b/app/src/main/java/hk/uwu/reareye/utils/other/AboutLibrariesTools.kt new file mode 100644 index 0000000..f8e5f69 --- /dev/null +++ b/app/src/main/java/hk/uwu/reareye/utils/other/AboutLibrariesTools.kt @@ -0,0 +1,532 @@ +package hk.uwu.reareye.utils.other + +import android.content.Context +import android.content.Intent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import hk.uwu.reareye.R +import hk.uwu.reareye.ui.components.RearBadgeGroup +import hk.uwu.reareye.ui.components.RearBadgeItem +import hk.uwu.reareye.ui.components.card.SuperCard +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import top.yukonga.miuix.kmp.basic.Button +import top.yukonga.miuix.kmp.basic.ButtonColors +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.HorizontalDivider +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.extended.Link +import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet +import top.yukonga.miuix.kmp.theme.MiuixTheme + +@Serializable +data class AboutLibraries( + val libraries: List, + val licenses: Map +) + +@Serializable +data class Library( + val uniqueId: String, + val artifactVersion: String, + val name: String, + val description: String? = null, + val website: String? = null, + val developers: List = emptyList(), + val organization: Organization? = null, + val licenses: List = emptyList() +) + +@Serializable +data class Developer( + val name: String? = null, + val organisationUrl: String? = null +) + +@Serializable +data class Organization( + val name: String, + val url: String? = null +) + +@Serializable +data class License( + val name: String, + val url: String, + val content: String? = null +) + +private val json = Json { + ignoreUnknownKeys = true +} + +private const val LicenseBadgeOverflowText = "..." +private const val LicenseCreditsAnimationDurationMillis = 220 + +fun loadLibraries(context: Context): AboutLibraries { + val inputStream = context.resources.openRawResource(R.raw.aboutlibraries) + + val jsonString = inputStream + .bufferedReader() + .use { it.readText() } + + return json.decodeFromString(jsonString) +} + +@Composable +fun LibraryItem(lib: Library, licenses: Map) { + val context = LocalContext.current + val hasLink = lib.website != null + var showLicenses by remember { mutableStateOf>(emptyList()) } + var expandLicensesInline by remember { mutableStateOf(false) } + + Card(modifier = Modifier.fillMaxWidth()) { + SuperCard( + title = lib.name, + summary = buildLibrarySummary(lib), + endActions = { + if (hasLink) { + Button( + colors = ButtonColors( + color = Color.Transparent, + disabledColor = Color.Transparent, + contentColor = Color.Transparent, + disabledContentColor = Color.Transparent, + ), + onClick = { + lib.website.let { targetLink -> + context.startActivity( + Intent( + Intent.ACTION_VIEW, + targetLink.toUri() + ) + ) + } + } + ) { + Icon( + imageVector = MiuixIcons.Link, + tint = MiuixTheme.colorScheme.onSurface, + contentDescription = null, + ) + } + } + }, + bottomAction = { + if (lib.developers.isNotEmpty() || lib.licenses.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier + .height(16.dp), + thickness = 1.dp, + color = MiuixTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + } + val licenseEntries = lib.licenses.map { licenseId -> + licenseId to licenses[licenseId] + } + LibraryCreditsRow( + developers = lib.developers, + licenseBadges = licenseEntries.map { (licenseId, license) -> + RearBadgeItem( + text = licenseBadgeText(licenseId, license), + emphasized = false, + onClick = license?.let { targetLicense -> + { showLicenses = listOf(targetLicense) } + }, + ) + }, + expanded = expandLicensesInline, + onExpand = { expandLicensesInline = true }, + onCollapse = { expandLicensesInline = false }, + ) + if (showLicenses.isNotEmpty()) { + val primaryColor = MiuixTheme.colorScheme.primary + val dialogTitle = remember(showLicenses) { + showLicenses.joinToString(" / ") { it.name } + } + OverlayBottomSheet( + show = true, + title = dialogTitle, + onDismissRequest = { showLicenses = emptyList() } + ) { + val rawText = remember(showLicenses) { + showLicenses.joinToString("\n\n") { license -> + buildString { + append(license.name) + append("\n\n") + append(license.content ?: license.url) + } + } + } + + val annotatedText = remember(rawText) { + buildAnnotatedString { + append(rawText) + val urlPattern = Regex("(https?://[\\w-]+(\\.[\\w-]+)+(/\\S*)?)") + urlPattern.findAll(rawText).forEach { match -> + addLink( + LinkAnnotation.Url( + url = match.value, + styles = TextLinkStyles( + SpanStyle( + color = primaryColor, + fontWeight = FontWeight.Bold, + ) + ) + ), + start = match.range.first, + end = match.range.last + 1 + ) + } + } + } + + Text( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 560.dp) + .padding(top = 8.dp) + .verticalScroll(rememberScrollState()), + text = annotatedText, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary + ) + } + } + + }, + ) + } +} + +private fun buildLibrarySummary(lib: Library): String? { + val parts = buildList { + add(lib.artifactVersion) + lib.organization?.name?.let { add(it) } + lib.description?.takeIf { it.isNotBlank() }?.let { add(it) } + } + return parts.takeIf { it.isNotEmpty() }?.joinToString("\n") +} + +private fun licenseBadgeText(licenseId: String, license: License?): String { + return license?.name ?: licenseId +} + +@Composable +private fun LibraryCreditsRow( + developers: List, + licenseBadges: List, + expanded: Boolean, + onExpand: () -> Unit, + onCollapse: () -> Unit, +) { + if (developers.isEmpty() && licenseBadges.isEmpty()) return + + val badgeSpacing = 12.dp + val authorWordSpacing = 4.dp + val authorText = buildLibraryDevelopersText(developers) + val authorWords = remember(authorText) { splitAnnotatedWords(authorText) } + val overflowBadges = remember(licenseBadges, onExpand) { + if (licenseBadges.isEmpty()) { + emptyList() + } else { + listOf( + RearBadgeItem( + text = LicenseBadgeOverflowText, + emphasized = false, + onClick = onExpand, + ) + ) + } + } + val collapsedBySpaceState = remember { mutableStateOf(false) } + val shouldExpand = expanded && collapsedBySpaceState.value + val authorTargetAlpha = if (shouldExpand) 0f else 1f + val authorAlpha by animateFloatAsState( + targetValue = authorTargetAlpha, + animationSpec = tween(durationMillis = LicenseCreditsAnimationDurationMillis), + label = "license-author-alpha", + ) + val fullBadgeAlpha by animateFloatAsState( + targetValue = if (!collapsedBySpaceState.value || shouldExpand) 1f else 0f, + animationSpec = tween(durationMillis = LicenseCreditsAnimationDurationMillis), + label = "license-full-badge-alpha", + ) + val overflowBadgeAlpha by animateFloatAsState( + targetValue = if (collapsedBySpaceState.value && !shouldExpand) 1f else 0f, + animationSpec = tween(durationMillis = LicenseCreditsAnimationDurationMillis), + label = "license-overflow-badge-alpha", + ) + val rowHeightProgress by animateFloatAsState( + targetValue = if (shouldExpand) 1f else 0f, + animationSpec = tween(durationMillis = LicenseCreditsAnimationDurationMillis), + label = "license-row-height-progress", + ) + + Layout( + modifier = Modifier + .fillMaxWidth() + .clickable( + enabled = shouldExpand, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onCollapse, + ), + content = { + authorWords.forEach { word -> + LibraryDevelopersText( + text = word, + modifier = Modifier.graphicsLayer { alpha = authorAlpha }, + ) + } + Box(contentAlignment = Alignment.CenterEnd) { + if (licenseBadges.isNotEmpty()) { + RearBadgeGroup( + badges = licenseBadges, + modifier = Modifier.graphicsLayer { alpha = fullBadgeAlpha }, + horizontalAlignment = Alignment.End, + ) + } + } + Box(contentAlignment = Alignment.CenterEnd) { + if (overflowBadges.isNotEmpty()) { + RearBadgeGroup( + badges = overflowBadges, + modifier = Modifier.graphicsLayer { alpha = overflowBadgeAlpha }, + horizontalAlignment = Alignment.End, + ) + } + } + }, + ) { measurables, constraints -> + val maxWidth = constraints.maxWidth + val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val wordMeasurables = measurables.take(authorWords.size) + val fullBadgeMeasurable = measurables[authorWords.size] + val overflowBadgeMeasurable = measurables[authorWords.size + 1] + val hasAuthor = authorWords.isNotEmpty() + val hasLicenses = licenseBadges.isNotEmpty() + val requestedBadgeSpacingPx = if (hasAuthor && hasLicenses) badgeSpacing.roundToPx() else 0 + val wordSpacingPx = authorWordSpacing.roundToPx() + + val overflowBadgePlaceable = overflowBadgeMeasurable.measure(looseConstraints) + val fullBadgePlaceable = + fullBadgeMeasurable.measure(looseConstraints.copy(maxWidth = maxWidth)) + val fullBadgeFitsOneLine = + !hasLicenses || fullBadgePlaceable.height <= overflowBadgePlaceable.height + val firstWordWidth = wordMeasurables.firstOrNull() + ?.maxIntrinsicWidth(constraints.maxHeight) + ?.coerceAtMost(maxWidth) + ?: 0 + val fullBadgeSpacingPx = requestedBadgeSpacingPx.coerceAtMost( + (maxWidth - fullBadgePlaceable.width).coerceAtLeast(0) + ) + val firstLineWidthWithFullBadge = + (maxWidth - fullBadgePlaceable.width - fullBadgeSpacingPx).coerceAtLeast(0) + val collapsedBySpace = hasLicenses && hasAuthor && + (!fullBadgeFitsOneLine || firstWordWidth > firstLineWidthWithFullBadge) + collapsedBySpaceState.value = collapsedBySpace + val collapsedBadgePlaceable = + if (collapsedBySpace) overflowBadgePlaceable else fullBadgePlaceable + val collapsedBadgeSpacingPx = requestedBadgeSpacingPx.coerceAtMost( + (maxWidth - collapsedBadgePlaceable.width).coerceAtLeast(0) + ) + val collapsedFirstLineMaxWidth = if (hasLicenses && hasAuthor) { + (maxWidth - collapsedBadgePlaceable.width - collapsedBadgeSpacingPx).coerceAtLeast(0) + } else { + maxWidth + } + + val expandedBadgePlaceable = fullBadgePlaceable + val expandedBadgeHeight = fullBadgePlaceable.height + + data class WordPlacement( + val placeableIndex: Int, + val x: Int, + val y: Int, + ) + + fun measureAuthor(firstLineMaxWidth: Int): Pair, List> { + val wordPlaceables = mutableListOf() + val wordPlacements = mutableListOf() + var lineIndex = 0 + var currentX = 0 + var currentY = 0 + var currentLineHeight = if (hasLicenses) collapsedBadgePlaceable.height else 0 + + fun currentLineMaxWidth(): Int { + return if (lineIndex == 0) firstLineMaxWidth else maxWidth + } + + fun moveToNextLine() { + currentY += currentLineHeight + lineIndex += 1 + currentX = 0 + currentLineHeight = 0 + } + + wordMeasurables.forEach { measurable -> + val intrinsicWidth = + measurable.maxIntrinsicWidth(constraints.maxHeight).coerceAtMost(maxWidth) + var lineMaxWidth = currentLineMaxWidth() + var wordX = if (currentX == 0) 0 else currentX + wordSpacingPx + + if (currentX > 0 && wordX + intrinsicWidth > lineMaxWidth) { + moveToNextLine() + lineMaxWidth = currentLineMaxWidth() + wordX = 0 + } + + val wordMaxWidth = (lineMaxWidth - wordX).takeIf { it > 0 } ?: lineMaxWidth + val placeable = measurable.measure( + looseConstraints.copy(maxWidth = intrinsicWidth.coerceAtMost(wordMaxWidth)) + ) + wordPlacements += WordPlacement( + placeableIndex = wordPlaceables.size, + x = wordX, + y = currentY, + ) + wordPlaceables += placeable + currentX = wordX + placeable.width + currentLineHeight = maxOf(currentLineHeight, placeable.height) + } + return wordPlaceables to wordPlacements + } + + val (wordPlaceables, wordPlacements) = measureAuthor(collapsedFirstLineMaxWidth) + val authorHeight = if (hasAuthor && wordPlacements.isNotEmpty()) { + wordPlacements.maxOf { placement -> + placement.y + wordPlaceables[placement.placeableIndex].height + } + } else { + 0 + } + val collapsedContentHeight = + maxOf(authorHeight, if (hasLicenses) collapsedBadgePlaceable.height else 0) + val expandedContentHeight = if (hasLicenses) expandedBadgeHeight else collapsedContentHeight + val layoutHeight = (collapsedContentHeight + + ((expandedContentHeight - collapsedContentHeight) * rowHeightProgress).toInt()) + .coerceIn(constraints.minHeight, constraints.maxHeight) + val collapsedBadgeY = ((layoutHeight - collapsedBadgePlaceable.height) / 2).coerceAtLeast(0) + val fullBadgeY = ((layoutHeight - expandedBadgePlaceable.height) / 2).coerceAtLeast(0) + + layout(maxWidth, layoutHeight) { + wordPlacements.forEach { placement -> + val placeable = wordPlaceables[placement.placeableIndex] + placeable.placeRelative(placement.x, placement.y) + } + if (hasLicenses) { + if (!collapsedBySpace || shouldExpand || fullBadgeAlpha > 0.01f) { + expandedBadgePlaceable.placeRelative( + maxWidth - expandedBadgePlaceable.width, + fullBadgeY + ) + } + if (collapsedBySpace && (!shouldExpand || overflowBadgeAlpha > 0.01f)) { + collapsedBadgePlaceable.placeRelative( + maxWidth - collapsedBadgePlaceable.width, + collapsedBadgeY + ) + } + } + } + } +} + +private fun splitAnnotatedWords(text: AnnotatedString): List { + if (text.isEmpty()) return emptyList() + + val words = mutableListOf() + var index = 0 + while (index < text.length) { + while (index < text.length && text[index] == ' ') { + index += 1 + } + val start = index + while (index < text.length && text[index] != ' ') { + index += 1 + } + if (start < index) { + words += text.subSequence(start, index) + } + } + return words +} + +@Composable +private fun buildLibraryDevelopersText(developers: List): AnnotatedString { + return buildAnnotatedString { + developers.forEachIndexed { index, developer -> + val name = developer.name ?: "Unknown" + val url = developer.organisationUrl + + if (url != null) { + val start = length + append(name) + addLink( + LinkAnnotation.Url( + url = url, + styles = TextLinkStyles( + SpanStyle( + color = MiuixTheme.colorScheme.primary, + ) + ) + ), + start = start, + end = length, + ) + } else { + append(name) + } + + if (index < developers.size - 1) { + append(", ") + } + } + } +} + +@Composable +private fun LibraryDevelopersText( + text: AnnotatedString, + modifier: Modifier = Modifier, +) { + Text( + modifier = modifier, + text = text, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/app/src/main/java/hk/uwu/reareye/utils/other/DeviceConfigTools.kt b/app/src/main/java/hk/uwu/reareye/utils/other/DeviceConfigTools.kt new file mode 100644 index 0000000..1f83a25 --- /dev/null +++ b/app/src/main/java/hk/uwu/reareye/utils/other/DeviceConfigTools.kt @@ -0,0 +1,60 @@ +package hk.uwu.reareye.utils.other + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import hk.uwu.reareye.utils.RootHelper.executeRootCommand +import hk.uwu.reareye.utils.RootHelper.hasRootAccess + +object DeviceConfigTools { + + // 我就说我设备名字怎么就对不上了,这玩意还要 Root 获取,破烂 + val getdevice = + if (hasRootAccess()) executeRootCommand("getprop persist.private.device_name") else Pair( + 20, + "No Root Permission" + ) + val deviceName = if (getdevice.first == 0) getdevice.second else "No Root Permission" + + + val androidVersion: String = getSystemProperties("ro.build.version.release") + + val marketName by lazy { + + val marketName: String = getSystemProperties("ro.product.marketname") + + if (marketName.isNotEmpty()) titleFirstChar(marketName) else titleFirstChar(Build.BRAND) + " " + Build.MODEL + + } + + fun titleFirstChar(st: String): String { + val formattedBrand = st.replaceFirstChar { + if (it.isLowerCase()) it.titlecase() else it.toString() + } + return formattedBrand + } + + @SuppressLint("PrivateApi") + + fun getSystemProperties(key: String): String { + val ret: String = try { + Class.forName("android.os.SystemProperties") + .getDeclaredMethod("get", String::class.java).invoke(null, key) as String + } catch (iAE: IllegalArgumentException) { + throw iAE + } catch (_: Exception) { + "" + } + return ret + } + + fun getSubScreenVersion(context: Context): String { + val packageManager = context.packageManager + return try { + val packageInfo = packageManager.getPackageInfo("com.xiaomi.subscreencenter", 0) + packageInfo.versionName.toString() + } catch (_: Exception) { + "UNKNOWN" + } + } +} diff --git a/app/src/main/java/hk/uwu/reareye/utils/other/OSVersionTools.kt b/app/src/main/java/hk/uwu/reareye/utils/other/OSVersionTools.kt new file mode 100644 index 0000000..869833b --- /dev/null +++ b/app/src/main/java/hk/uwu/reareye/utils/other/OSVersionTools.kt @@ -0,0 +1,99 @@ +package hk.uwu.reareye.utils.other + +import android.content.Context +import android.util.Log +import hk.uwu.reareye.R +import hk.uwu.reareye.utils.other.DeviceConfigTools.getSystemProperties +import java.util.regex.Pattern + +object OSVersionTools { + + private const val TAG = "OSVersionTools" + private val VERSION_PATTERN: Pattern = Pattern.compile("^[a-zA-Z][0-9]{1,3}$") + + fun getXmsVersion(): String { + return getSystemProperties("persist.sys.xms.version") + } + + fun getRoXmsVersion(): String { + return getSystemProperties("ro.mi.xms.version.incremental") + } + + fun getOsVersionCode(): String { + val str = getSystemProperties("ro.mi.os.version.incremental") + return if (str.isEmpty() || !str.startsWith("OS") || str.length <= 2) { + str + } else { + str.substring(2) + } + } + + fun addVersionSuffix(context: Context?): String { + var xmsVersion = getXmsVersion() + val roXmsVersion = getRoXmsVersion() + val osVersionCode = getOsVersionCode() + + val isXmsValid = isValid(xmsVersion) + val isRoValid = isValid(roXmsVersion) + + if (!isXmsValid && !isRoValid) { + return osVersionCode + } + + if (!isXmsValid || isRoValid) { + xmsVersion = if (isXmsValid) { + compareValidVersion(xmsVersion, roXmsVersion) + } else { + roXmsVersion + } + } + + return insertSuffixBeforeBeta(context, osVersionCode, xmsVersion) + } + + private fun isValid(str: String?): Boolean { + return !str.isNullOrEmpty() && VERSION_PATTERN.matcher(str).matches() + } + + private fun compareValidVersion(s: String, s2: String): String { + val c1 = s[0].lowercaseChar() + val c2 = s2[0].lowercaseChar() + + if (c1 != c2) { + return if (c1 > c2) s else s2 + } + + return try { + val v1 = s.substring(1).toInt() + val v2 = s2.substring(1).toInt() + if (v1 >= v2) s else s2 + } catch (e: Exception) { + Log.d(TAG, "compareValidVersion: parse failed $e") + s + } + } + + private fun insertSuffixBeforeBeta( + context: Context?, + base: String, + suffix: String + ): String { + var tail: String? = null + + val devString = context?.getString(R.string.developer_build) + + if (devString != null && base.endsWith(devString)) { + tail = devString + } + + if (base.endsWith("Beta")) { + tail = "Beta" + } + + return if (tail != null) { + base.removeSuffix(tail).trim() + ".$suffix $tail" + } else { + "$base.$suffix" + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher.png b/app/src/main/res/drawable/ic_launcher.png index b9cb898..a6a4e06 100644 Binary files a/app/src/main/res/drawable/ic_launcher.png and b/app/src/main/res/drawable/ic_launcher.png differ diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 96175dd..b4c83a6 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -8,6 +8,18 @@ 仓库 配置 关于 + 组件管理 + 卡片管理 + 歌词管理 + 编辑 + 编辑快捷入口 + 最多显示 %1$d 个快捷入口 + 正在显示 + 可用快捷入口 + 暂无快捷入口 + 没有更多可用快捷入口 + 保存 + 取消 配置 GitHub 仓库 查看源码、问题反馈与发布版本 @@ -77,6 +89,10 @@ 启用自定义音乐控件白名单 白名单应用 选择哪些应用的音乐控件可以在背屏中显示 + 锁屏返回桌面白名单 + 主屏锁屏进入 AOD 时,阻止背屏中心把选中的应用切回背屏桌面 + 白名单应用 + 选择主屏锁屏后需要继续留在背屏、不要返回背屏桌面的应用 后台白名单 使选中的应用保持在后台运行 启用后台白名单 @@ -183,13 +199,13 @@ 可编辑资源 第三方资源 支持 AON - 组件模板管理器 + 组件管理 管理 组件 到模板文件的映射 - 通知路由管理器 + 通知路由 根据规则把应用通知映射到组件 - 组件额外设置管理器 + 组件额外设置 手动添加需要额外显示选项的组件 - 卡片管理器 + 卡片管理 管理常驻卡片、显示顺序与启用状态 允许焦点通知在背屏显示 必须先存在该焦点通知对应的组件或模板,否则系统仍会拒绝显示 @@ -339,6 +355,26 @@ 当前版本暂不支持%1$s安装模式 当前模块版本不支持安装该组件 缺失组件配置文件 + 组件要求未满足 + 校验组件依赖应用需要已授予应用列表权限 + 缺少应用:%1$s + 无法安装 + 未知配置:%1$s + 请先设置%1$s + 请打开%1$s + 请关闭%1$s + %1$s不应处于开启状态 + %1$s不应处于关闭状态 + %1$s应不小于%2$s + %1$s应不大于%2$s + %1$s应为%2$s + %1$s不应为%2$s + %1$s应大于%2$s + %1$s应小于%2$s + %1$s应包含%2$s + %1$s不应包含%2$s + %1$s应为%2$s + %1$s不应为%2$s 安装提示 组件 %1$s 已经安装,是否继续覆盖当前安装? 组件 %1$s 已存在,是否继续覆盖当前注册? @@ -357,6 +393,8 @@ 下载中 %1$d%% 安装中 + 安装后操作 + 使用浏览器打开 组件 壁纸 卡片 @@ -386,6 +424,7 @@ Root 不可用 部分功能依赖 Root 权限, 请在Root管理器中授予本应用Root权限 模块版本 + 版本代号 快捷终止背屏中心 快捷终止主题管理 已终止 %1$s @@ -432,6 +471,12 @@ 设置视频壁纸的播放音量 禁用背屏保护 启用此功能后,当主屏处于全屏模式时,背屏将不会被锁定 + 背屏双击息屏禁用白名单 + 选择需要让系统框架忽略背屏双击息屏/AOD 手势的应用 + 背屏双击唤醒禁用白名单 + 选择在前台时需要忽略背屏双击唤醒的应用 + 背屏高负载模式移除白名单 + 选择在主屏前台时需要跳过背屏高负载模式处理的应用 更多调试输出 歌词 管理歌词配置 @@ -460,4 +505,88 @@ 临无人所及之境 呈巅峰造极形态 非凡大师 + 开发版 + 设备型号 + OS 版本 + Android 版本 + 背屏版本 + 半透明 + 引用 + 查看我们引用了哪些库 + 自定义界限 + 按应用管理背屏显示界限、DPI 与旋转行为 + 选择应用 + 选择需要使用背屏自定义界限的应用 + 自定义界限 + 为每个应用单独调整背屏显示界限。仅作用于背屏显示。 + 暂无已配置应用 + 编辑应用界限 + 当前应用:%1$s + 调整此应用在背屏上的显示范围。对齐位置和旋转角度使用固定选项,避免写入无效系统参数。 + 为此应用启用 + 宽高比 + 模式 + 可选自动比例、自定义比例或精确边距。 + 自动比例 + 自定义比例 + 精确边距 + 自动使用背屏自身的分辨率比例。 + 左边距 + 上边距 + 右边距 + 下边距 + 对齐位置 + 选择应用界限在背屏容器中的停靠方向。 + 缩放 + DPI(留空跟随系统) + 旋转角度 + 选择固定的背屏旋转角度,或保持跟随系统。 + 填充空白区域 + 填充模式 + 自动模式会优先使用应用启动/背景颜色;自定义模式使用下方颜色。 + 自动取色 + 自定义颜色 + 自动取应用启动/背景颜色,取不到时回退到系统明暗色。 + 调色板 + 输入十六进制颜色,例如 #FF000000 或 #000000。 + 填充颜色 + 颜色值无效 + 请填写有效的界限配置 + 界限配置已保存 + 比例 %1$s · 缩放 %2$s · 位置 %3$d + DPI %1$s · 旋转 %2$s · %3$s + 跟随系统 + %1$d° + 居中 + 靠上 + 靠下 + 靠左 + 靠右 + 自定义 %1$d + 跟随系统 + + 90° + 180° + 270° + 比例 %1$s + 模式 %1$s + 边距 %1$s + 缩放 %1$s + 位置 %1$s + DPI %1$s + 旋转 %1$s + 已启用 + 已停用 + 未填充 + 填充 %1$s + 规则已导出 + 规则导出失败 + 规则已导入 + 规则文件无效或已损坏 + 导入失败 + 拒绝导入 + 规则目标应用 %1$s 不存在,已拒绝导入。 + 覆盖规则? + %1$s 已有自定义界限规则,是否覆盖? + 覆盖 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0417260..2f838b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,6 +8,18 @@ Store Config About + Widget Management + Card Management + Lyrics Management + Edit + Edit Quick Actions + Show up to %1$d quick actions + Shown + Available Quick Actions + No quick actions selected + No more quick actions available + Save + Cancel Configuration GitHub Repository Source code, issues and releases @@ -77,6 +89,10 @@ Enable Custom Music Controls Whitelist Whitelist Apps Choose which apps music controls can be displayed on the rear screen + Lock Screen Home Return Whitelist + Prevent Subscreen Center from pulling the rear Home to front for selected apps when AOD starts after locking the main screen + Whitelist Apps + Choose apps that should stay on the rear screen instead of returning to Subscreen Home after locking Background Whitelist Keep the selected application running in the background Enable Background Whitelist @@ -183,13 +199,13 @@ Editable resource Third-party resource Support AON - Widget Template Manager + Widget Management Manage widget-to-template mapping only, without package route injection - Notification Route Manager + Notification Route Map application rule notifications to widgets - Widget Extra Settings Manager + Widget Extra Settings Manually add widgets that need extra display options - Card Manager + Card Management Manage resident cards, display order, and enabled state Allow focus notices on rear screen Allow notifications that already carry valid focus/rear params to pass the rear-screen whitelist. The matching widget business must already exist, otherwise the system will still reject it. @@ -339,6 +355,26 @@ Widget: %2$s · Priority: %3$d Current version does not support %1$s install mode Current module version does not support installing this widget Missing widget config file + Widget requirements are not satisfied + Installed apps permission is required to verify widget package requirements + Missing app: %1$s + Unable to Install + Unknown config: %1$s + Please set %1$s + Turn on %1$s + Turn off %1$s + %1$s should not be turned on + %1$s should not be turned off + %1$s should be at least %2$s + %1$s should be at most %2$s + %1$s should be %2$s + %1$s should not be %2$s + %1$s should be greater than %2$s + %1$s should be less than %2$s + %1$s should contain %2$s + %1$s should not contain %2$s + %1$s should be %2$s + %1$s should not be %2$s Install Warning Widget %1$s is already installed. Continue to overwrite the current installation? Widget %1$s already exists. Continue to overwrite the current registration? @@ -357,6 +393,8 @@ Widget: %2$s · Priority: %3$d Downloading %1$d%% Installing + Post-Install + Open in Browser Widget Wallpaper Card @@ -386,6 +424,7 @@ Widget: %2$s · Priority: %3$d Root unavailable Some features require root privileges. Please grant this application root privileges using your root manager Module Version + Version Codename Force stop Subscreen Center Force stop Theme Manager Stopped %1$s @@ -432,6 +471,12 @@ Widget: %2$s · Priority: %3$d Set playback volume for video wallpapers Disable rear screen cover When enabled, the rear screen will not be locked when the main screen is in full-screen mode + Rear Double-Tap Sleep Disabled Apps + Choose apps where the system framework should ignore rear double-tap sleep/AOD gestures + Rear Double-Tap Wake Disabled Apps + Choose apps where rear double-tap wake should be ignored while they are in the foreground + Rear High Load Mode Disabled Apps + Choose main-screen foreground apps where rear high-load mode handling should be skipped More debug output Lyrics Manage lyrics options @@ -460,4 +505,88 @@ Widget: %2$s · Priority: %3$d Explore the ultimate frontier Unfold in front of you ULTIMATE DESIGN + DEV + Device Name + OS Version + Android Version + SubSceen Version + Semi-transparent + Quote + See which libraries we have referenced + Custom Bounds + Manage each app\'s rear-screen bounds, DPI and rotation behavior + Select Apps + Choose apps that should use custom rear-screen bounds + Custom Bounds + Configure rear-screen display bounds per app. It only applies to the rear display. + No app configured yet + Edit App Bounds + Current app: %1$s + Adjust how this app is placed on the rear screen. Position and rotation use fixed options to avoid invalid system gravity values. + Enable for this app + Aspect ratio + Mode + Choose automatic ratio, custom ratio, or exact insets. + Auto ratio + Custom ratio + Exact insets + Uses the rear screen resolution ratio automatically. + Left inset + Top inset + Right inset + Bottom inset + Position + Choose where the app bounds should anchor within the rear-screen container. + Scale + DPI (empty follows system) + Rotation + Choose a fixed rear-screen rotation, or keep following the system. + Fill empty area + Fill mode + Auto uses the app splash/background color when available; custom uses the color below. + Auto color + Custom color + Automatically samples the app splash/background color, falling back to the system light/dark color. + Palette + Enter a hex color such as #FF000000 or #000000. + Fill color + Invalid color value + Please enter valid bounds settings + Bounds settings saved + Ratio %1$s · Scale %2$s · Gravity %3$d + DPI %1$s · Rotation %2$s · %3$s + Follow system + %1$d° + Center + Top + Bottom + Left + Right + Custom %1$d + Follow system + + 90° + 180° + 270° + Ratio %1$s + Mode %1$s + Insets %1$s + Scale %1$s + Position %1$s + DPI %1$s + Rotation %1$s + Enabled + Disabled + Fill off + Fill %1$s + Rule exported + Failed to export rule + Rule imported + The rule file is invalid or corrupted + Import failed + Import rejected + Target app %1$s does not exist. Import was rejected. + Overwrite rule? + %1$s already has a custom bounds rule. Overwrite it? + Overwrite diff --git a/build.gradle.kts b/build.gradle.kts index 3cd7a9e..45807e4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,5 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.ksp) apply false alias(libs.plugins.compose.compiler) apply false + id("com.mikepenz.aboutlibraries.plugin") version "14.1.0" apply false } diff --git a/gradle.properties b/gradle.properties index 32af4e5..6accec8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,4 +16,5 @@ project.android.targetSdk=37 project.android.buildToolsVersion=37.0.0 project.android.compileSdkMinor=0 project.app.packageName=hk.uwu.reareye -project.app.versionName="1.0.6" +project.app.versionName="1.0.7" +project.app.versionCodename="CircuitBreaker" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 656d847..9a23488 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "9.1.1" -kotlin = "2.3.20" +kotlin = "2.3.21" +kotlinx-serialization-json = "1.11.0" ksp = "2.3.6" rovo89-xposed-api = "82" yukihookapi = "1.3.1" @@ -11,8 +12,8 @@ okhttp-logging = "5.3.2" junit = "4.13.2" androidx-test-junit = "1.3.0" androidx-test-espresso-core = "3.7.0" -compose-bom = "2026.03.01" -compose-foundation-style = "1.11.0-rc01" +compose-bom = "2026.05.00" +compose-foundation-style = "1.11.1" androidx-activity-compose = "1.13.0" androidx-lifecycle-runtime-compose = "2.10.0" miuix = "0.9.0" @@ -21,12 +22,14 @@ backdrop = "1.0.6" capsule = "2.1.3" lyricon = "0.1.70" superlyric = "3.3" -gson = "2.13.2" -dexkit = "2.1.0" -foundation-layout = "1.10.6" +gson = "2.14.0" +dexkit = "2.2.0" +foundation-layout = "1.11.1" mmkv = "2.4.0" +compose-icons = "2.2.1" [plugins] +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } @@ -34,6 +37,7 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = " [libraries] androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } rovo89-xposed-api = { group = "de.robv.android.xposed", name = "api", version.ref = "rovo89-xposed-api" } yukihookapi = { group = "com.highcapable.yukihookapi", name = "api", version.ref = "yukihookapi" } yukihookapi-ksp-xposed = { group = "com.highcapable.yukihookapi", name = "ksp-xposed", version.ref = "yukihookapi" } @@ -69,3 +73,10 @@ gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } dexkit = { group = "org.luckypray", name = "dexkit", version.ref = "dexkit" } androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundation-layout" } mmkv = { group = "com.tencent", name = "mmkv-static", version.ref = "mmkv" } +compose-icons-material-symbols-outlined-cmp = { group = "com.composables", name = "icons-material-symbols-outlined-cmp", version.ref = "compose-icons" } +compose-icons-material-symbols-rounded-cmp = { group = "com.composables", name = "icons-material-symbols-rounded-cmp", version.ref = "compose-icons" } +compose-icons-material-symbols-sharp-cmp = { group = "com.composables", name = "icons-material-symbols-sharp-cmp", version.ref = "compose-icons" } +compose-icons-material-symbols-outlined-filled-cmp = { group = "com.composables", name = "icons-material-symbols-outlined-filled-cmp", version.ref = "compose-icons" } +compose-icons-material-symbols-rounded-filled-cmp = { group = "com.composables", name = "icons-material-symbols-rounded-filled-cmp", version.ref = "compose-icons" } +compose-icons-material-symbols-sharp-filled-cmp = { group = "com.composables", name = "icons-material-symbols-sharp-filled-cmp", version.ref = "compose-icons" } + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 035ae8c..5b0fe58 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Mar 09 17:02:30 CST 2026 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME \ No newline at end of file diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000..cb2204e --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,48 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# + +################################################################################# +# WARNING: Do not store sensitive information in this file, # +# as its contents will be included in the Qodana report. # +################################################################################# +version: "1.0" + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +projectJDK: "21" #(Applied in CI/CD pipeline) + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +# Quality gate. Will fail the CI/CD pipeline if any condition is not met +# severityThresholds - configures maximum thresholds for different problem severities +# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code +# Code Coverage is available in Ultimate and Ultimate Plus plans +#failureConditions: +# severityThresholds: +# any: 15 +# critical: 5 +# testCoverageThresholds: +# fresh: 70 +# total: 50 + +#Specify Qodana linter for analysis (Applied in CI/CD pipeline) +linter: jetbrains/qodana-jvm-android:2025.3