fix: bugs

This commit is contained in:
AnranYus
2025-10-16 17:32:58 +08:00
parent 289aef4d84
commit f43429806a
15 changed files with 267 additions and 53 deletions
@@ -7,6 +7,7 @@
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:name=".ComfyApp"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
@@ -0,0 +1,17 @@
package moe.uni.comfy.comfyuikmp
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
class ComfyApp: Application() {
companion object{
@SuppressLint("StaticFieldLeak")
lateinit var context: Context
}
override fun onCreate() {
super.onCreate()
context = applicationContext
}
}
@@ -0,0 +1,58 @@
package moe.uni.comfy.comfyuikmp.util
import android.content.ContentValues
import android.graphics.Bitmap
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import moe.uni.comfy.comfyuikmp.ComfyApp
import java.io.File
actual suspend fun saveImageToLocal(image: ImageBitmap, name: String): Result<Boolean> {
val bitmap = image.asAndroidBitmap()
val imagesCollection =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
} else {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "$name.png")
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/ComfyApp")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
val resolver = ComfyApp.context.contentResolver
val uri = resolver.insert(imagesCollection, contentValues)
uri?.let {
try {
resolver.openOutputStream(uri)?.use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
} ?: throw kotlinx.io.IOException("OpenOutputStream returned null")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, contentValues, null, null)
}
return Result.success(true)
} catch (e: Exception) {
// 出错时删除占位条目,避免相册里出现空文件
runCatching { resolver.delete(uri, null, null) }
e.printStackTrace()
return Result.failure(e)
}
}
return Result.failure(kotlinx.io.IOException("Failed to create new MediaStore record."))
}
@@ -4,6 +4,11 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
object NodeType{
const val KSampler = "KSampler"
}
/**
* 通用 ComfyUI 节点模型,尽量保持结构宽松以兼容不同节点类型与输入形态。
*/
@@ -6,12 +6,14 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import moe.uni.comfy.comfyuikmp.data.model.ComfyPrompt
import moe.uni.comfy.comfyuikmp.data.settings.KeyValueStore
import moe.uni.comfy.comfyuikmp.data.model.ImageRef
@Serializable
data class WorkflowItem(
val id: String,
val name: String,
val prompt: ComfyPrompt
val prompt: ComfyPrompt,
val cover: ImageRef? = null
)
class WorkflowRepository {
@@ -1,16 +1,21 @@
package moe.uni.comfy.comfyuikmp.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonPrimitive
import moe.uni.comfy.comfyuikmp.data.model.NodeType
import moe.uni.comfy.comfyuikmp.di.AppGraph
import moe.uni.comfy.comfyuikmp.ui.gallery.GalleryScreen
import moe.uni.comfy.comfyuikmp.ui.generate.GenerateScreen
@@ -21,6 +26,7 @@ import moe.uni.comfy.comfyuikmp.ui.navigation.rememberNavController
import moe.uni.comfy.comfyuikmp.ui.settings.SettingsScreen
import moe.uni.comfy.comfyuikmp.ui.view.ViewScreen
import moe.uni.comfy.comfyuikmp.util.mainViewModelFactory
import moe.uni.comfy.comfyuikmp.util.saveImageToLocal
import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
@@ -31,6 +37,7 @@ fun AppRoot(viewModel: AppViewModel = viewModel(factory = mainViewModelFactory))
val hasSaved = remember { AppGraph.workflows.list().isNotEmpty() }
val startDest = if (hasSaved) Dest.Home else Dest.Settings
val navController = rememberNavController(startDest)
MaterialTheme {
val snackbar = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
@@ -72,11 +79,29 @@ fun AppRoot(viewModel: AppViewModel = viewModel(factory = mainViewModelFactory))
},
actions = {
if (current == Dest.Generate) {
if (uiState.image!=null){
Icon(Icons.Filled.Save, contentDescription = null, modifier = Modifier.clickable{
uiState.image?.let {
scope.launch {
val result = saveImageToLocal(it,"${uiState.workflowName}_${Random.nextInt()}.png")
if (result.isSuccess){
snackbar.showSnackbar("保存成功")
}else{
snackbar.showSnackbar(result.exceptionOrNull()?.message?:"保存失败")
}
}
}
})
Spacer(Modifier.size(16.dp))
}
Icon(
Icons.Filled.History,
contentDescription = null,
modifier = Modifier.clickable { navController.navigate(Dest.Gallery) }
)
Spacer(Modifier.size(16.dp))
}
}
)
@@ -90,7 +115,7 @@ fun AppRoot(viewModel: AppViewModel = viewModel(factory = mainViewModelFactory))
uiState.prompt.let { p ->
scope.launch {
if (uiState.useRandom) {
p.values.find { it.classType == "KSampler" }?.let {
p.values.find { it.classType == NodeType.KSampler }?.let {
val inputs = it.inputs.toMutableMap()
inputs["seed"] = JsonPrimitive(Random.nextInt(0, Int.MAX_VALUE))
it.inputs = inputs
@@ -145,7 +170,8 @@ fun AppRoot(viewModel: AppViewModel = viewModel(factory = mainViewModelFactory))
viewModel.useRandomSeed(it)
},
onOpenImage = { ref -> viewModel.openImage(ref) }
onOpenImage = { ref -> viewModel.openImage(ref) },
onSetCover = { }
)
}
@@ -23,6 +23,7 @@ import moe.uni.comfy.comfyuikmp.data.network.createDefaultHttpClient
import moe.uni.comfy.comfyuikmp.data.repository.WorkflowItem
import moe.uni.comfy.comfyuikmp.di.AppGraph
import moe.uni.comfy.comfyuikmp.util.decodeImage
import moe.uni.comfy.comfyuikmp.util.saveImageToLocal
class AppViewModel : ViewModel() {
private val _uiState = MutableStateFlow(AppUiState())
@@ -145,5 +146,5 @@ data class AppUiState(
val image: ImageBitmap? = null,
val progress:ProgressUpdate = ProgressUpdate(0f, ""),
val prompt: ComfyPrompt = emptyMap(),
val useRandom: Boolean = false
val useRandom: Boolean = true
)
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -23,6 +24,12 @@ import coil3.compose.AsyncImage
import moe.uni.comfy.comfyuikmp.di.AppGraph
import moe.uni.comfy.comfyuikmp.ui.AppViewModel
import moe.uni.comfy.comfyuikmp.ui.navigation.Dest
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import moe.uni.comfy.comfyuikmp.util.buildImageUrl
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -42,23 +49,12 @@ fun GalleryScreen(contentPadding: PaddingValues, viewModel: AppViewModel) {
LazyColumn(contentPadding = contentPadding, modifier = Modifier.padding(horizontal = 16.dp)) {
items(history, key = { it.filename }) { img ->
val url = buildString {
append(base)
append("/view?filename=")
append(img.filename)
img.subfolder?.let {
append("&subfolder=")
append(it)
}
img.type?.let {
append("&type=")
append(it)
}
}
val url = buildImageUrl(img)
Card(modifier = Modifier.fillMaxWidth().aspectRatio(3f / 4f).clickable {
viewModel.openImage(img)
}) {
Card(modifier = Modifier.fillMaxWidth().aspectRatio(3f / 4f).combinedClickable(
onClick = { viewModel.openImage(img) }
)) {
AsyncImage(
model = url,
contentDescription = null,
@@ -26,6 +26,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.ui.Alignment
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
@@ -36,6 +37,9 @@ import moe.uni.comfy.comfyuikmp.data.model.updateNodeStringInput
import moe.uni.comfy.comfyuikmp.data.model.updateNodePrimitiveInput
import moe.uni.comfy.comfyuikmp.data.network.ProgressUpdate
import moe.uni.comfy.comfyuikmp.data.model.ImageRef
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import moe.uni.comfy.comfyuikmp.data.model.NodeType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -49,6 +53,7 @@ fun GenerateScreen(
onPromptChange: (ComfyPrompt) -> Unit,
onRandomSwitchChange: (Boolean) -> Unit,
onOpenImage: (ImageRef) -> Unit = {},
onSetCover: (ImageRef) -> Unit = {},
) {
val state = rememberBottomSheetScaffoldState()
Scaffold(modifier = Modifier.padding(contentPadding)) {
@@ -141,7 +146,8 @@ fun GenerateScreen(
)
//KSampler
if (node.classType == "KSampler" && key.equals("seed", ignoreCase = true)) {
if (node.classType == NodeType.KSampler && key.equals("seed", ignoreCase = true)) {
inputEnable = !useRandom
Row(modifier = Modifier.padding(top = 8.dp)) {
Spacer(Modifier.width(8.dp))
Switch(useRandom, onCheckedChange = {
@@ -189,6 +195,7 @@ fun GenerateScreen(
Alignment.Center))
}
var showSetCover by remember { mutableStateOf(false) }
if (latestImage != null) {
if (latestImageRef != null) {
Image(
@@ -197,7 +204,10 @@ fun GenerateScreen(
modifier = Modifier
.fillMaxWidth()
.padding(top = 32.dp)
.clickable { onOpenImage(latestImageRef) }
.combinedClickable(
onClick = { onOpenImage(latestImageRef) },
onLongClick = { showSetCover = true }
)
)
} else {
Image(
@@ -207,6 +217,21 @@ fun GenerateScreen(
)
}
}
if (showSetCover && latestImageRef != null) {
AlertDialog(
onDismissRequest = { showSetCover = false },
confirmButton = {
Button(onClick = {
onSetCover(latestImageRef)
showSetCover = false
}) { Text("设为封面") }
},
dismissButton = { Button(onClick = { showSetCover = false }) { Text("取消") } },
title = { Text("设置封面") },
text = { Text("将当前图片设为该工作流的封面?") }
)
}
}
}
}
@@ -6,12 +6,16 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -28,6 +32,10 @@ import androidx.compose.material3.Icon
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.Alignment
import coil3.compose.AsyncImage
import moe.uni.comfy.comfyuikmp.util.buildImageUrl
@Composable
@@ -49,27 +57,48 @@ fun HomeScreen(
) { Text("新建/导入工作流", modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.titleMedium) }
}
items(items) { w ->
Card(modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth()
.clickable { onPick(w) }
Card(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth()
.clickable { onPick(w) },
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Text(w.name, modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.titleMedium)
Row(modifier = Modifier.padding(horizontal = 8.dp)) {
IconButton(onClick = { renaming = w; newName = w.name }) { Icon(Icons.Filled.Edit, contentDescription = null,
Modifier.size(24.dp)) }
IconButton(onClick = { AppGraph.workflows.delete(w.id); items = repo.list() }) { Icon(Icons.Filled.Delete, contentDescription = null,
Modifier.size(24.dp)) }
if (w.cover != null) {
AsyncImage(
model = buildImageUrl(w.cover),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f)
)
} else {
// 占位封面区域
androidx.compose.foundation.layout.Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f)
)
}
Column(modifier = Modifier.padding(12.dp)) {
Text(w.name, style = MaterialTheme.typography.titleMedium)
Row(modifier = Modifier.padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { renaming = w; newName = w.name }) { Icon(Icons.Filled.Edit, contentDescription = null,
Modifier.size(24.dp)) }
IconButton(onClick = { AppGraph.workflows.delete(w.id); items = repo.list() }) { Icon(Icons.Filled.Delete, contentDescription = null,
Modifier.size(24.dp)) }
}
}
}
}
}
val r = renaming
if (r != null) {
if (renaming != null) {
AlertDialog(onDismissRequest = { renaming = null }, confirmButton = {
Button(onClick = {
AppGraph.workflows.rename(r.id, newName.ifBlank { r.name })
AppGraph.workflows.rename(renaming!!.id, newName.ifBlank { renaming!!.name })
items = repo.list()
renaming = null
}) { Text("确定") }
@@ -19,24 +19,13 @@ import moe.uni.comfy.comfyuikmp.data.model.ImageRef
import moe.uni.comfy.comfyuikmp.di.AppGraph
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.geometry.Offset
import moe.uni.comfy.comfyuikmp.util.buildImageUrl
@Composable
fun ViewScreen(ref: ImageRef, contentPadding: PaddingValues) {
val base = AppGraph.settings.getBaseUrl() ?: ""
val url = remember(ref, base) {
buildString {
append(base)
append("/view?filename=")
append(ref.filename)
ref.subfolder?.let {
append("&subfolder=")
append(it)
}
ref.type?.let {
append("&type=")
append(it)
}
}
buildImageUrl(ref)
}
val scaleState = remember { mutableFloatStateOf(1f) }
@@ -0,0 +1,27 @@
package moe.uni.comfy.comfyuikmp.util
import androidx.compose.ui.graphics.ImageBitmap
import moe.uni.comfy.comfyuikmp.data.model.ImageRef
import moe.uni.comfy.comfyuikmp.di.AppGraph
fun buildImageUrl(ref: ImageRef): String {
val base = AppGraph.settings.getBaseUrl() ?: ""
return buildString {
append(base)
append("/view?filename=")
append(ref.filename)
ref.subfolder?.let {
append("&subfolder=")
append(it)
}
ref.type?.let {
append("&type=")
append(it)
}
}
}
expect suspend fun saveImageToLocal(image: ImageBitmap, name: String): Result<Boolean>
@@ -19,11 +19,6 @@ actual fun createPlatformHttpClient(configure: HttpClientConfig<*>.() -> Unit):
install(UserAgent) {
agent = "ComfyUIKMP-iOS"
}
defaultRequest {
url {
protocol = URLProtocol.HTTP
}
}
configure()
}
}
@@ -0,0 +1,8 @@
package moe.uni.comfy.comfyuikmp.util
import androidx.compose.ui.graphics.ImageBitmap
import coil3.Bitmap
actual suspend fun saveImageToLocal(image: ImageBitmap, name: String): Result<Boolean> {
return Result.failure(kotlinx.io.IOException("Failed to create new MediaStore record."))
}
+35
View File
@@ -4,12 +4,47 @@
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>需要访问 ComfyUI 服务以生成图像</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<!-- 示例:开发期允许局域网 IP 和自签名证书。可按需添加具体域名/IP。 -->
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>127.0.0.1</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>10.0.0.0/8</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>172.16.0.0/12</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>192.168.0.0/16</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
</dict>
</plist>