add delta sync for dm-v12

This commit is contained in:
eric 2026-08-05 17:54:18 +08:00
parent ce9c146576
commit 76229174c0
3 changed files with 107 additions and 6 deletions

View file

@ -7,6 +7,8 @@
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />

View file

@ -56,6 +56,8 @@ import androidx.compose.material.icons.filled.ContactPage
import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.VideoCall
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
@ -69,6 +71,7 @@ import androidx.compose.ui.text.style.TextOverflow
// 1. 定義目前支援的選單類型 // 1. 定義目前支援的選單類型
enum class AttachmentType(val title: String) { enum class AttachmentType(val title: String) {
MEDIA("Media"), MEDIA("Media"),
VIDEO("Video"),
AUDIO("Audio"), AUDIO("Audio"),
DOCUMENT("Document"), DOCUMENT("Document"),
LOCATION("Location"), LOCATION("Location"),
@ -91,6 +94,7 @@ data class AttachmentOption(
@Composable @Composable
fun MessageAttachmentBar( fun MessageAttachmentBar(
onImageSelected: (Uri) -> Unit, onImageSelected: (Uri) -> Unit,
onVideoSelected: (Uri) -> Unit,
onAudioSelected: (Uri) -> Unit, // 🎯 新增:音訊選取回呼 onAudioSelected: (Uri) -> Unit, // 🎯 新增:音訊選取回呼
onCancel: () -> Unit, onCancel: () -> Unit,
onDocumentClick: () -> Unit = {}, onDocumentClick: () -> Unit = {},
@ -104,6 +108,7 @@ fun MessageAttachmentBar(
// 資料狀態 // 資料狀態
val localImages = remember { mutableStateListOf<Uri>() } val localImages = remember { mutableStateListOf<Uri>() }
val localVideos = remember { mutableStateListOf<Uri>() }
val localAudios = remember { mutableStateListOf<AudioItem>() } val localAudios = remember { mutableStateListOf<AudioItem>() }
var refreshTrigger by remember { mutableStateOf(0) } var refreshTrigger by remember { mutableStateOf(0) }
@ -112,6 +117,7 @@ fun MessageAttachmentBar(
val attachmentOptions = remember { val attachmentOptions = remember {
listOf( listOf(
AttachmentOption("圖片", Icons.Default.Image, { currentType = AttachmentType.MEDIA }), AttachmentOption("圖片", Icons.Default.Image, { currentType = AttachmentType.MEDIA }),
AttachmentOption("影片", Icons.Default.VideoCall, { currentType = AttachmentType.VIDEO }),
AttachmentOption("音訊", Icons.Default.Audiotrack, { currentType = AttachmentType.AUDIO }), AttachmentOption("音訊", Icons.Default.Audiotrack, { currentType = AttachmentType.AUDIO }),
AttachmentOption("文件", Icons.Default.Description, { currentType = AttachmentType.DOCUMENT; onDocumentClick() }), AttachmentOption("文件", Icons.Default.Description, { currentType = AttachmentType.DOCUMENT; onDocumentClick() }),
AttachmentOption("位置", Icons.Default.LocationOn, { currentType = AttachmentType.LOCATION; onLocationClick() }), AttachmentOption("位置", Icons.Default.LocationOn, { currentType = AttachmentType.LOCATION; onLocationClick() }),
@ -119,12 +125,14 @@ fun MessageAttachmentBar(
) )
} }
// Android 14+ 圖片部分權限檢查 // Android 14+ 媒體部分權限檢查
val isPartialAccess by remember(refreshTrigger) { val isPartialAccess by remember(refreshTrigger) {
derivedStateOf { derivedStateOf {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) == PackageManager.PERMISSION_GRANTED && val partial = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) == PackageManager.PERMISSION_GRANTED
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_IMAGES) != PackageManager.PERMISSION_GRANTED val imageFull = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_IMAGES) == PackageManager.PERMISSION_GRANTED
val videoFull = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_VIDEO) == PackageManager.PERMISSION_GRANTED
partial && (!imageFull || !videoFull)
} else { } else {
false false
} }
@ -157,6 +165,28 @@ fun MessageAttachmentBar(
} }
} }
// 🎯 新增:讀取影片的 Effect
LaunchedEffect(refreshTrigger) {
val videoUris = mutableListOf<Uri>()
val projection = arrayOf(MediaStore.Video.Media._ID, MediaStore.Video.Media.DATE_TAKEN)
val sortOrder = "${MediaStore.Video.Media.DATE_TAKEN} DESC"
try {
context.contentResolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, projection, null, null, sortOrder)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
var count = 0
while (cursor.moveToNext() && count < 24) {
val id = cursor.getLong(idColumn)
videoUris.add(ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id))
count++
}
}
localVideos.clear()
localVideos.addAll(videoUris)
} catch (e: SecurityException) {
localVideos.clear()
}
}
// 🎯 4. 新增:讀取音訊的 Effect (記得在 AndroidManifest 宣告 READ_EXTERNAL_STORAGE 或 READ_MEDIA_AUDIO) // 🎯 4. 新增:讀取音訊的 Effect (記得在 AndroidManifest 宣告 READ_EXTERNAL_STORAGE 或 READ_MEDIA_AUDIO)
LaunchedEffect(refreshTrigger) { LaunchedEffect(refreshTrigger) {
val audioItems = mutableListOf<AudioItem>() val audioItems = mutableListOf<AudioItem>()
@ -295,6 +325,67 @@ fun MessageAttachmentBar(
} }
} }
AttachmentType.VIDEO -> {
// 影片網格邏輯
if (localVideos.isEmpty() && !isPartialAccess) {
EmptyStateView("無最近影片檔案")
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (isPartialAccess) {
item {
Box(
modifier = Modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.1f))
.clickable {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
permissionLauncher.launch(Manifest.permission.READ_MEDIA_VIDEO)
}
},
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(imageVector = Icons.Default.Add, contentDescription = "管理影片", tint = Color.White)
Text("管理檔案", color = Color.White, style = MaterialTheme.typography.labelSmall)
}
}
}
}
items(localVideos) { uri ->
Box(
modifier = Modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
.clickable { onVideoSelected(uri) }
) {
AsyncImage(
model = uri,
contentDescription = "影片預覽",
modifier = Modifier.fillMaxSize(),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
// 影片圖示疊加
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier
.size(32.dp)
.align(Alignment.Center)
)
}
}
}
}
}
AttachmentType.AUDIO -> { AttachmentType.AUDIO -> {
// 🎯 5. 新增:音訊渲染 UI (音訊檔名較長,改用垂直捲動清單比網格好看) // 🎯 5. 新增:音訊渲染 UI (音訊檔名較長,改用垂直捲動清單比網格好看)
if (localAudios.isEmpty()) { if (localAudios.isEmpty()) {
@ -360,6 +451,7 @@ fun MessageAttachmentBar(
// 🎯 6. 稍微優化:如果選中當前項目,給予特別的高亮白底 // 🎯 6. 稍微優化:如果選中當前項目,給予特別的高亮白底
val isSelected = option.title == when(currentType) { val isSelected = option.title == when(currentType) {
AttachmentType.MEDIA -> "圖片" AttachmentType.MEDIA -> "圖片"
AttachmentType.VIDEO -> "影片"
AttachmentType.AUDIO -> "音訊" AttachmentType.AUDIO -> "音訊"
AttachmentType.DOCUMENT -> "文件" AttachmentType.DOCUMENT -> "文件"
AttachmentType.LOCATION -> "位置" AttachmentType.LOCATION -> "位置"

View file

@ -195,6 +195,7 @@ fun MessageMessaging(
val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
arrayOf( arrayOf(
Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.READ_MEDIA_AUDIO Manifest.permission.READ_MEDIA_AUDIO
) )
} else { } else {
@ -207,11 +208,12 @@ fun MessageMessaging(
// 【Android 14+】 // 【Android 14+】
// 音訊必須要過 // 音訊必須要過
val audioGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_AUDIO) == PackageManager.PERMISSION_GRANTED val audioGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_AUDIO) == PackageManager.PERMISSION_GRANTED
// 圖片不管是「全過(READ_MEDIA_IMAGES)」還是「部分過(READ_MEDIA_VISUAL_USER_SELECTED)」都可以算過! // 圖片/影片不管是「全過」還是「部分過」都可以算過!
val imageGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_IMAGES) == PackageManager.PERMISSION_GRANTED val imageGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_IMAGES) == PackageManager.PERMISSION_GRANTED
val partialImageGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) == PackageManager.PERMISSION_GRANTED val videoGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_VIDEO) == PackageManager.PERMISSION_GRANTED
val partialVisualGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) == PackageManager.PERMISSION_GRANTED
audioGranted && (imageGranted || partialImageGranted) audioGranted && (imageGranted || videoGranted || partialVisualGranted)
} else { } else {
// 【Android 13 及以下】 // 【Android 13 及以下】
requiredPermissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } requiredPermissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
@ -493,6 +495,11 @@ fun MessageMessaging(
// 上傳後自動退回輸入框 // 上傳後自動退回輸入框
localBottomBarState = null localBottomBarState = null
}, },
onVideoSelected = { uri ->
// 🎯 使用者選取了影片,觸發上傳
viewModel.uploadAttachment(roomId, uri)
localBottomBarState = null
},
onAudioSelected = { uri -> onAudioSelected = { uri ->
// 處理音訊上傳的邏輯(通常跟圖片上傳類似,或是呼叫你專門處理音訊的函式) // 處理音訊上傳的邏輯(通常跟圖片上傳類似,或是呼叫你專門處理音訊的函式)
viewModel.uploadAttachment(roomId, uri) // 假設你的 ViewModel 上傳函式是通用的 viewModel.uploadAttachment(roomId, uri) // 假設你的 ViewModel 上傳函式是通用的