Add free adjustment of image position when selecting background

- Updated AGP version to 8.9.2.
- Added support for Android 16 (36).
- Replaced the new API and fixed some minor bugs.

Signed-off-by: ShirkNeko <109797057+ShirkNeko@users.noreply.github.com>
This commit is contained in:
ShirkNeko
2025-04-27 04:05:49 +08:00
parent d73670bf43
commit c442f43090
10 changed files with 448 additions and 155 deletions

View File

@@ -0,0 +1,140 @@
package com.sukisu.ultra.ui.component
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.util.BackgroundTransformation
import com.sukisu.ultra.ui.util.saveTransformedBackground
@Composable
fun ImageEditorDialog(
imageUri: Uri,
onDismiss: () -> Unit,
onConfirm: (Uri) -> Unit
) {
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
val context = LocalContext.current
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.9f))
) {
// 主图片区域
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUri)
.crossfade(true)
.build(),
contentDescription = stringResource(R.string.settings_custom_background),
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY
)
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(0.5f, 3f)
// 限制平移范围,防止图片完全移出屏幕
val maxOffset = size.width * (scale - 1) / 2
offsetX = (offsetX + pan.x).coerceIn(-maxOffset, maxOffset)
offsetY = (offsetY + pan.y).coerceIn(-maxOffset, maxOffset)
}
}
)
// 顶部工具栏
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.align(Alignment.TopCenter),
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(
onClick = onDismiss,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color.Black.copy(alpha = 0.6f))
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.cancel),
tint = Color.White
)
}
IconButton(
onClick = {
val transformation = BackgroundTransformation(scale, offsetX, offsetY)
val savedUri = context.saveTransformedBackground(imageUri, transformation)
savedUri?.let { onConfirm(it) }
},
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color.Black.copy(alpha = 0.6f))
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(R.string.confirm),
tint = Color.White
)
}
}
// 底部提示
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.Black.copy(alpha = 0.6f))
.padding(16.dp)
.align(Alignment.BottomCenter)
) {
Text(
text = stringResource(id = R.string.image_editor_hint),
color = Color.White,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}

View File

@@ -35,6 +35,7 @@ import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import com.sukisu.ultra.ui.component.ImageEditorDialog
import com.sukisu.ultra.ui.component.SwitchItem
import com.sukisu.ultra.ui.theme.*
import com.sukisu.ultra.ui.util.*
@@ -140,6 +141,10 @@ fun MoreSettingsScreen(navigator: DestinationsNavigator) {
mutableStateOf(ThemeConfig.customBackgroundUri != null)
}
// 图片编辑状态
var showImageEditor by remember { mutableStateOf(false) }
var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
// 初始化卡片配置
val systemIsDark = isSystemInDarkTheme()
LaunchedEffect(Unit) {
@@ -183,11 +188,29 @@ fun MoreSettingsScreen(navigator: DestinationsNavigator) {
ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let {
context.saveCustomBackground(it)
selectedImageUri = it
showImageEditor = true
}
}
// 显示图片编辑对话框
if (showImageEditor && selectedImageUri != null) {
ImageEditorDialog(
imageUri = selectedImageUri!!,
onDismiss = {
showImageEditor = false
selectedImageUri = null
},
onConfirm = { transformedUri ->
context.saveAndApplyCustomBackground(transformedUri)
isCustomBackgroundEnabled = true
CardConfig.cardElevation = 0.dp
CardConfig.isCustomBackgroundEnabled = true
saveCardConfig(context)
showImageEditor = false
selectedImageUri = null
}
)
}
Scaffold(
@@ -439,6 +462,7 @@ fun MoreSettingsScreen(navigator: DestinationsNavigator) {
CardConfig.cardElevation = CardConfig.defaultElevation
CardConfig.cardAlpha = 0.45f
CardConfig.isCustomAlphaSet = false
CardConfig.isCustomBackgroundEnabled = false
saveCardConfig(context)
cardAlpha = 0.35f
themeMode = 0

View File

@@ -1,5 +1,7 @@
package com.sukisu.ultra.ui.screen
import android.content.ClipData
import android.content.ClipboardManager
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@@ -44,11 +46,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ramcosta.composedestinations.annotation.Destination
@@ -99,8 +100,8 @@ fun AppProfileTemplateScreen(
Scaffold(
topBar = {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
val clipboardManager = context.getSystemService<ClipboardManager>()
val showToast = fun(msg: String) {
scope.launch(Dispatchers.Main) {
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
@@ -112,21 +113,21 @@ fun AppProfileTemplateScreen(
scope.launch { viewModel.fetchTemplates(true) }
},
onImport = {
clipboardManager.getText()?.text?.let {
if (it.isEmpty()) {
showToast(context.getString(R.string.app_profile_template_import_empty))
return@let
}
scope.launch {
val clipboardText = clipboardManager?.primaryClip?.getItemAt(0)?.text?.toString()
if (clipboardText.isNullOrEmpty()) {
showToast(context.getString(R.string.app_profile_template_import_empty))
return@launch
}
viewModel.importTemplates(
it, {
clipboardText,
{
showToast(context.getString(R.string.app_profile_template_import_success))
viewModel.fetchTemplates(false)
},
showToast
)
}
}
},
onExport = {
scope.launch {
@@ -134,8 +135,8 @@ fun AppProfileTemplateScreen(
{
showToast(context.getString(R.string.app_profile_template_export_empty))
}
) {
clipboardManager.setText(AnnotatedString(it))
) { text ->
clipboardManager?.setPrimaryClip(ClipData.newPlainText("", text))
}
}
},

View File

@@ -27,11 +27,14 @@ import androidx.compose.ui.zIndex
import coil.compose.rememberAsyncImagePainter
import androidx.compose.foundation.background
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.unit.dp
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import androidx.core.content.edit
import androidx.core.net.toUri
import com.sukisu.ultra.ui.util.BackgroundTransformation
import com.sukisu.ultra.ui.util.saveTransformedBackground
object ThemeConfig {
var customBackgroundUri by mutableStateOf<Uri?>(null)
@@ -88,29 +91,6 @@ private fun getLightColorScheme() = lightColorScheme(
outlineVariant = Color.Black.copy(alpha = 0.12f)
)
// 复制图片到应用内部存储
fun Context.copyImageToInternalStorage(uri: Uri): Uri? {
try {
val contentResolver: ContentResolver = contentResolver
val inputStream: InputStream = contentResolver.openInputStream(uri)!!
val fileName = "custom_background.jpg"
val file = File(filesDir, fileName)
val outputStream = FileOutputStream(file)
val buffer = ByteArray(4 * 1024)
var read: Int
while (inputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
}
outputStream.flush()
outputStream.close()
inputStream.close()
return Uri.fromFile(file)
} catch (e: Exception) {
Log.e("ImageCopy", "Failed to copy image: ${e.message}")
return null
}
}
@Composable
fun KernelSUTheme(
darkTheme: Boolean = when(ThemeConfig.forceDarkMode) {
@@ -131,7 +111,6 @@ fun KernelSUTheme(
if (darkTheme) {
val originalScheme = dynamicDarkColorScheme(context)
originalScheme.copy(
// 调整按钮相关颜色
primary = adjustColor(originalScheme.primary),
onPrimary = adjustColor(originalScheme.onPrimary),
primaryContainer = adjustColor(originalScheme.primaryContainer),
@@ -242,6 +221,48 @@ fun KernelSUTheme(
}
}
// 复制图片到应用内部存储
private fun Context.copyImageToInternalStorage(uri: Uri): Uri? {
try {
val contentResolver: ContentResolver = contentResolver
val inputStream: InputStream = contentResolver.openInputStream(uri)!!
val fileName = "custom_background.jpg"
val file = File(filesDir, fileName)
val outputStream = FileOutputStream(file)
val buffer = ByteArray(4 * 1024)
var read: Int
while (inputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
}
outputStream.flush()
outputStream.close()
inputStream.close()
return Uri.fromFile(file)
} catch (e: Exception) {
Log.e("ImageCopy", "Failed to copy image: ${e.message}")
return null
}
}
// 保存变换后的背景图片到应用内部存储并更新配置
fun Context.saveAndApplyCustomBackground(uri: Uri, transformation: BackgroundTransformation? = null) {
val finalUri = if (transformation != null) {
saveTransformedBackground(uri, transformation)
} else {
copyImageToInternalStorage(uri)
}
getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
.edit {
putString("custom_background", finalUri?.toString())
}
ThemeConfig.customBackgroundUri = finalUri
CardConfig.cardElevation = 0.dp
CardConfig.isCustomBackgroundEnabled = true
}
// 保存背景图片到应用内部存储并更新配置
fun Context.saveCustomBackground(uri: Uri?) {
val newUri = uri?.let { copyImageToInternalStorage(it) }
getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
@@ -249,6 +270,10 @@ fun Context.saveCustomBackground(uri: Uri?) {
putString("custom_background", newUri?.toString())
}
ThemeConfig.customBackgroundUri = newUri
if (uri != null) {
CardConfig.cardElevation = 0.dp
CardConfig.isCustomBackgroundEnabled = true
}
}
fun Context.loadCustomBackground() {

View File

@@ -0,0 +1,98 @@
package com.sukisu.ultra.ui.util
import android.content.ContentResolver
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Matrix
import android.net.Uri
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import androidx.core.graphics.createBitmap
data class BackgroundTransformation(
val scale: Float = 1f,
val offsetX: Float = 0f,
val offsetY: Float = 0f
)
fun Context.getImageBitmap(uri: Uri): Bitmap? {
return try {
val contentResolver: ContentResolver = contentResolver
val inputStream: InputStream = contentResolver.openInputStream(uri) ?: return null
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream.close()
bitmap
} catch (e: Exception) {
Log.e("BackgroundUtils", "Failed to get image bitmap: ${e.message}")
null
}
}
fun Context.applyTransformationToBitmap(bitmap: Bitmap, transformation: BackgroundTransformation): Bitmap {
val width = bitmap.width
val height = bitmap.height
// 创建与屏幕比例相同的目标位图
val displayMetrics = resources.displayMetrics
val screenWidth = displayMetrics.widthPixels
val screenHeight = displayMetrics.heightPixels
val screenRatio = screenHeight.toFloat() / screenWidth.toFloat()
// 计算目标宽高
val targetWidth: Int
val targetHeight: Int
if (width.toFloat() / height.toFloat() > screenRatio) {
targetHeight = height
targetWidth = (height / screenRatio).toInt()
} else {
targetWidth = width
targetHeight = (width * screenRatio).toInt()
}
// 创建与目标相同大小的位图
val scaledBitmap = createBitmap(targetWidth, targetHeight)
val canvas = Canvas(scaledBitmap)
val matrix = Matrix()
matrix.postScale(transformation.scale, transformation.scale)
// 计算中心点
val centerX = targetWidth / 2f
val centerY = targetHeight / 2f
// 缩放围绕中心点
matrix.postTranslate(
-((bitmap.width * transformation.scale - targetWidth) / 2) + transformation.offsetX,
-((bitmap.height * transformation.scale - targetHeight) / 2) + transformation.offsetY
)
// 将原始位图绘制到新位图上
canvas.drawBitmap(bitmap, matrix, null)
return scaledBitmap
}
fun Context.saveTransformedBackground(uri: Uri, transformation: BackgroundTransformation): Uri? {
try {
val bitmap = getImageBitmap(uri) ?: return null
val transformedBitmap = applyTransformationToBitmap(bitmap, transformation)
val fileName = "custom_background_transformed.jpg"
val file = File(filesDir, fileName)
val outputStream = FileOutputStream(file)
transformedBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
outputStream.flush()
outputStream.close()
return Uri.fromFile(file)
} catch (e: Exception) {
Log.e("BackgroundUtils", "Failed to save transformed image: ${e.message}")
return null
}
}

View File

@@ -173,7 +173,6 @@
<string name="restore_allowlist">許可リストを復元</string>
<string name="settings_custom_background">カスタム背景を設定</string>
<string name="settings_custom_background_summary">カスタム背景を設定します</string>
<string name="settings_card_manage">カードの管理</string>
<string name="settings_card_alpha">ナビゲーションバーの透過</string>
<string name="settings_restore_default">デフォルトに復元</string>
<string name="home_android_version">Android のバージョン</string>

View File

@@ -262,4 +262,7 @@
<string name="invalid_file_type">文件类型不正确,请选择 .kpm 文件</string>
<string name="confirm_uninstall_title_with_filename">卸载</string>
<string name="confirm_uninstall_content">将卸载以下 kpm 模块:\n%s</string>
<string name="image_editor_title">调整背景图片</string>
<string name="image_editor_hint">使用双指缩放图片,单指拖动调整位置</string>
<string name="background_image_error">无法加载图片</string>
</resources>

View File

@@ -266,4 +266,7 @@
<string name="confirm_uninstall_title_with_filename">Uninstall</string>
<string name="confirm_uninstall_content">The following KPM will be uninstalled: %s</string>
<string name="settings_susfs_toggle_summary">Disable kprobe hooks created by KernelSU, using inline hooks instead, which is similar to non-GKI kernel hooking method.</string>
<string name="image_editor_title">Adjust background image</string>
<string name="image_editor_hint">Use two fingers to zoom the image, and one finger to drag it to adjust the position</string>
<string name="background_image_error">Could not load image</string>
</resources>

View File

@@ -28,8 +28,8 @@ cmaker {
}
val androidMinSdkVersion = 26
val androidTargetSdkVersion = 35
val androidCompileSdkVersion = 35
val androidTargetSdkVersion = 36
val androidCompileSdkVersion = 36
val androidCompileNdkVersion = "28.0.13004108"
val androidSourceCompatibility = JavaVersion.VERSION_21
val androidTargetCompatibility = JavaVersion.VERSION_21

View File

@@ -1,25 +1,25 @@
[versions]
agp = "8.9.1"
agp = "8.9.2"
kotlin = "2.1.10"
ksp = "2.1.10-1.0.30"
compose-bom = "2025.02.00"
compose-bom = "2025.04.01"
lifecycle = "2.8.7"
navigation = "2.8.7"
activity-compose = "1.10.0"
kotlinx-coroutines = "1.10.1"
navigation = "2.8.9"
activity-compose = "1.10.1"
kotlinx-coroutines = "1.10.2"
coil-compose = "2.7.0"
compose-destination = "2.1.0-beta16"
compose-destination = "2.1.0"
sheets-compose-dialogs = "1.3.0"
markdown = "4.6.2"
webkit = "1.12.1"
webkit = "1.13.0"
appiconloader-coil = "1.5.0"
parcelablelist = "2.0.1"
libsu = "6.0.0"
apksign = "1.4"
cmaker = "1.2"
compose-material = "1.7.8"
compose-material3 = "1.3.1"
compose-ui = "1.7.8"
compose-material = "1.8.0"
compose-material3 = "1.3.2"
compose-ui = "1.8.0"
compose-foundation = "1.7.8"
documentfile = "1.0.1"