Преглед изворни кода

feat: 图片预览支持保存和下滑退出

wutiaorong пре 1 година
родитељ
комит
d74b8063b2

+ 52 - 0
app/src/main/java/com/adealink/weparty/commonui/imageview/FusionPreviewImageView.kt

@@ -1,11 +1,15 @@
 package com.adealink.weparty.commonui.imageview
 
+import android.annotation.SuppressLint
 import android.content.Context
 import android.util.AttributeSet
+import android.view.GestureDetector
+import android.view.MotionEvent
 import android.webkit.URLUtil
 import android.widget.FrameLayout
 import com.adealink.weparty.commonui.ext.isGIFImage
 import com.adealink.frame.image.view.NetworkImageView
+import com.adealink.frame.image.view.zoomable.IExtraListener
 import com.adealink.frame.image.view.zoomable.ZoomableDraweeView
 import com.adealink.frame.util.ImageUtil
 import com.facebook.drawee.backends.pipeline.Fresco
@@ -21,6 +25,7 @@ class FusionPreviewImageView : FrameLayout {
 
     private var netWorkImageView: NetworkImageView? = null
     private var zoomableDrawView: ZoomableDraweeView? = null
+    var extraListener: IExtraListener? = null
 
     constructor(context: Context) : this(context, null)
     constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
@@ -30,6 +35,33 @@ class FusionPreviewImageView : FrameLayout {
         defStyleAttr
     )
 
+    //用于GIF图长按和下滑检测
+    private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
+        private val SWIPE_THRESHOLD = 100
+        private val SWIPE_VELOCITY_THRESHOLD = 100
+
+        override fun onFling(
+            e1: MotionEvent?,
+            e2: MotionEvent,
+            velocityX: Float,
+            velocityY: Float
+        ): Boolean {
+            if (e1 == null) return false
+            val deltaY = e2.y - e1.y
+            if (deltaY > SWIPE_THRESHOLD && Math.abs(velocityY) > SWIPE_VELOCITY_THRESHOLD) {
+                extraListener?.onSwipeDown()
+                return true
+            }
+            return false
+        }
+
+        override fun onLongPress(e: MotionEvent) {
+            super.onLongPress(e)
+            extraListener?.onLongClick()
+        }
+    })
+
+    @SuppressLint("ClickableViewAccessibility")
     fun setImagePath(urlPath: String) {
         removeAllViews()
         if (isGIFImage(urlPath)) {
@@ -43,6 +75,16 @@ class FusionPreviewImageView : FrameLayout {
                         ImageUtil.getImageUri(urlPath)?.toString()
                     }
                 )
+                it.setOnLongClickListener {
+                    extraListener?.onLongClick()
+                    true
+                }
+                it.setOnTouchListener { _, event ->
+                    // 将触摸事件交给 GestureDetector 处理
+                    gestureDetector.onTouchEvent(event)
+                    true
+                }
+
                 addView(it)
             }
         } else {
@@ -58,6 +100,16 @@ class FusionPreviewImageView : FrameLayout {
                         }
                     ).setTapToRetryEnabled(true)
                     .build()
+                it.setExtraListener(object : IExtraListener {
+                    override fun onLongClick() {
+                        extraListener?.onLongClick()
+                    }
+
+                    override fun onSwipeDown() {
+                        extraListener?.onSwipeDown()
+                    }
+
+                })
                 it.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
                 val hierarchy = GenericDraweeHierarchyBuilder(resources)
                     .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER)

+ 1 - 1
gradle/libs.versions.toml

@@ -149,7 +149,7 @@ appleAppauth = "0.11.1"
 tiktok = "2.3.0"
 
 # frame
-frameBom = "5.1.17-yoki-12"
+frameBom = "5.1.17-yoki-14"
 frameRouterCompiler = "5.1.4"
 
 toolargetool = "0.3.0"

+ 2 - 1
module/image/src/main/java/com/adealink/weparty/image/data/Tags.kt

@@ -4,4 +4,5 @@ package com.adealink.weparty.image.data
  * Created by sunxiaodong on 2022/7/19.
  */
 const val TAG_VIDEO = "tag_video"
-const val TAG_VIDEO_DOWNLOAD = "${TAG_VIDEO}_download"
+const val TAG_VIDEO_DOWNLOAD = "${TAG_VIDEO}_download"
+const val TAG_IMAGE_PREVIEW = "tag_image_preview"

+ 103 - 0
module/image/src/main/java/com/adealink/weparty/image/preview/ImagePreviewFragment.kt

@@ -1,15 +1,31 @@
 package com.adealink.weparty.image.preview
 
+import android.Manifest
+import android.annotation.SuppressLint
+import android.os.Build
 import android.os.Bundle
+import android.util.Log
+import androidx.fragment.app.viewModels
+import com.adealink.frame.aab.util.getCompatString
+import com.adealink.frame.image.view.zoomable.IExtraListener
 import com.adealink.frame.mvvm.view.viewBinding
 import com.adealink.weparty.commonui.BaseFragment
+import com.adealink.weparty.commonui.dialogfragment.CommonTextActionDialog
+import com.adealink.weparty.commonui.toast.util.showToast
+import com.adealink.weparty.commonui.widget.CommonDialog
 import com.adealink.weparty.databinding.FragmentImagePreviewBinding
+import com.adealink.weparty.image.R
+import com.adealink.weparty.image.data.TAG_IMAGE_PREVIEW
+import com.adealink.weparty.image.viewmodel.ImagePreviewExtensionViewModel
+import com.adealink.weparty.image.viewmodel.Resource
+import com.adealink.weparty.permission.PermissionUtils
 import com.adealink.weparty.R as APP_R
 
 
 class ImagePreviewFragment : BaseFragment(APP_R.layout.fragment_image_preview) {
     companion object {
         private const val KEY_URL = "url"
+        private const val STORAGE_PERMISSION_DIALOG = "storage_permission_dialog"
         fun newInstance(uri: String): ImagePreviewFragment {
             val fragment = ImagePreviewFragment()
             val bundle = Bundle()
@@ -20,6 +36,7 @@ class ImagePreviewFragment : BaseFragment(APP_R.layout.fragment_image_preview) {
     }
 
     private val binding by viewBinding(FragmentImagePreviewBinding::bind)
+    private val imagePreviewExtensionViewModel by viewModels<ImagePreviewExtensionViewModel>()
     private var imageUri = ""
 
     override fun onCreate(savedInstanceState: Bundle?) {
@@ -27,8 +44,94 @@ class ImagePreviewFragment : BaseFragment(APP_R.layout.fragment_image_preview) {
         imageUri = arguments?.getString(KEY_URL) ?: ""
     }
 
+    override fun observeViewModel() {
+        super.observeViewModel()
+        imagePreviewExtensionViewModel.saveResult.observe(viewLifecycleOwner) {
+            when(it) {
+                is Resource.Success -> {
+                    showToast(R.string.image_save_success)
+
+                }
+                is Resource.Error -> {
+                    Log.e(TAG_IMAGE_PREVIEW, "saveResult error, e:${it.error?.message}")
+                    showToast(R.string.image_save_failed)
+                }
+                is Resource.Loading -> {
+                    //Ntd.
+                }
+            }
+        }
+    }
+
+    private fun showLongClickActionDialog() {
+        CommonTextActionDialog.Builder()
+            .addItem(R.id.image_preview_save, APP_R.string.common_save)
+            .actionListener(object : CommonTextActionDialog.CommonTextActionListener {
+                override fun onActionClick(viewId: Int) {
+                    when (viewId) {
+                        R.id.image_preview_save -> {
+                            checkStoragePermissionAndSaveImg(imageUri)
+                        }
+                    }
+                }
+            }).build().show(childFragmentManager)
+    }
+
     override fun initViews() {
         super.initViews()
+        binding.ivPhoto.extraListener = object : IExtraListener {
+            override fun onLongClick() {
+                //长按菜单
+                showLongClickActionDialog()
+            }
+
+            override fun onSwipeDown() {
+                //下滑退出页面
+                activity?.finish()
+            }
+        }
         binding.ivPhoto.setImagePath(imageUri)
     }
+
+    //保存图片需要检查存储权限
+    @SuppressLint("CheckResult")
+    private fun checkStoragePermissionAndSaveImg(imageUri: String) {
+        val act = activity ?: return
+        val permission = getMediaImagePermission()
+        if (PermissionUtils.hasPermissions(act, *permission)) {
+            imagePreviewExtensionViewModel.saveImageToAlbum(imageUri)
+        } else {
+            PermissionUtils.getRxPermissions(act)
+                .request(*permission).subscribe { granted ->
+                    if (granted) {
+                        imagePreviewExtensionViewModel.saveImageToAlbum(imageUri)
+                    } else {
+                        showPermissionNoGrantedDialog()
+                    }
+                }
+        }
+    }
+
+    private fun getMediaImagePermission(): Array<String> {
+        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+            arrayOf(Manifest.permission.READ_MEDIA_IMAGES)
+        } else {
+            arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+        }
+    }
+
+    private fun showPermissionNoGrantedDialog() {
+        childFragmentManager.findFragmentByTag(STORAGE_PERMISSION_DIALOG)?.let {
+            childFragmentManager.beginTransaction().remove(it).commitNowAllowingStateLoss()
+        }
+        val permissionDialog = CommonDialog.Builder()
+            .message(getCompatString(R.string.image_save_storage_permission_no_granted))
+            .onPositive {
+                PermissionUtils.startPermissionSettingActivity(this)
+            }
+            .dismissAfterClick(true)
+            .setShowDefaultCancel(true)
+            .build()
+        permissionDialog.show(childFragmentManager, STORAGE_PERMISSION_DIALOG)
+    }
 }

+ 218 - 0
module/image/src/main/java/com/adealink/weparty/image/viewmodel/ImagePreviewExtensionViewModel.kt

@@ -0,0 +1,218 @@
+package com.adealink.weparty.image.viewmodel
+
+import android.annotation.SuppressLint
+import android.content.ContentValues
+import android.media.MediaScannerConnection
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import android.webkit.URLUtil
+import androidx.lifecycle.MutableLiveData
+import com.adealink.frame.coroutine.dispatcher.Dispatcher
+import com.adealink.frame.mvvm.viewmodel.BaseViewModel
+import com.adealink.frame.util.AppUtil
+import com.facebook.common.executors.CallerThreadExecutor
+import com.facebook.common.memory.PooledByteBuffer
+import com.facebook.common.memory.PooledByteBufferInputStream
+import com.facebook.common.references.CloseableReference
+import com.facebook.datasource.BaseDataSubscriber
+import com.facebook.datasource.DataSource
+import com.facebook.drawee.backends.pipeline.Fresco
+import com.facebook.imagepipeline.common.RotationOptions
+import com.facebook.imagepipeline.request.ImageRequestBuilder
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.io.InputStream
+import kotlin.coroutines.resumeWithException
+
+//TODO wtr 这部分较粗糙 待整理
+class ImagePreviewExtensionViewModel : BaseViewModel() {
+    val saveResult = MutableLiveData<Resource<Uri>>()
+
+    fun saveImageToAlbum(imageUri: String) {
+        viewModelScope.launch {
+            saveResult.postValue(Resource.loading())
+            try {
+                val savedUri = withContext(Dispatcher.WENEXT_THREAD_POOL) {
+                    when {
+                        URLUtil.isNetworkUrl(imageUri) -> handleNetworkImage(imageUri)
+                        imageUri.startsWith("content://") -> handleContentUri(imageUri)
+                        imageUri.startsWith("file://") -> handleFileUri(imageUri)
+                        else -> throw IllegalArgumentException("Unsupported uri type")
+                    }
+                }
+                saveResult.postValue(Resource.success(savedUri))
+            } catch (e: Exception) {
+                saveResult.postValue(Resource.error(e))
+            }
+        }
+    }
+
+    // 处理网络图片
+    private suspend fun handleNetworkImage(url: String): Uri {
+        return downloadAndSaveImage(url)
+    }
+
+    private suspend fun downloadAndSaveImage(url: String): Uri =
+        suspendCancellableCoroutine { cancellableContinuation ->
+            val uri = Uri.parse(url)
+            val imageRequest = ImageRequestBuilder.newBuilderWithSource(uri)
+                .setRotationOptions(RotationOptions.autoRotate())
+                .build()
+
+            val dataSource = Fresco.getImagePipeline().fetchEncodedImage(imageRequest, null)
+
+            dataSource.subscribe(
+                object : BaseDataSubscriber<CloseableReference<PooledByteBuffer>>() {
+                    override fun onNewResultImpl(dataSource: DataSource<CloseableReference<PooledByteBuffer>>) {
+                        if (!dataSource.isFinished) return
+
+                        try {
+                            val ref = dataSource.result ?: throw IOException("Empty image data")
+                            val imageStream: InputStream = PooledByteBufferInputStream(ref.get())
+                            val fileName = URLUtil.guessFileName(uri.toString(), null, null)
+                            val savedUri = saveToGallery(imageStream, fileName)
+                            cancellableContinuation.resume(savedUri, null)
+                        } catch (e: Exception) {
+                            cancellableContinuation.resumeWithException(e)
+                        } finally {
+                            CloseableReference.closeSafely(dataSource.result)
+                            dataSource.close()
+                        }
+                    }
+
+                    override fun onFailureImpl(dataSource: DataSource<CloseableReference<PooledByteBuffer>>) {
+                        cancellableContinuation.resumeWithException(
+                            dataSource.failureCause ?: IOException("Unknown error")
+                        )
+                        dataSource.close()
+                    }
+
+                    override fun onProgressUpdate(progress: DataSource<CloseableReference<PooledByteBuffer>>) {
+                        // 可选:处理进度更新
+                    }
+                },
+                CallerThreadExecutor.getInstance() // 指定回调执行线程
+            )
+
+            // 协程取消时关闭 DataSource
+            cancellableContinuation.invokeOnCancellation {
+                dataSource.close()
+            }
+        }
+
+    // 处理 Content Uri
+    private fun handleContentUri(uriString: String): Uri {
+        val uri = Uri.parse(uriString)
+        return AppUtil.appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
+            saveToGallery(
+                inputStream = inputStream,
+                displayName = getFileNameFromContentUri(uri)
+            )
+        } ?: throw FileNotFoundException("Cannot open content uri")
+    }
+
+    // 处理文件路径
+    private fun handleFileUri(uriString: String): Uri {
+        val file = File(uriString.replace("file://", ""))
+        if (!file.exists()) throw FileNotFoundException("File not exists")
+
+        return saveToGallery(
+            inputStream = FileInputStream(file),
+            displayName = file.name
+        )
+    }
+
+    // 核心保存方法(适配 Android 10+)
+    @SuppressLint("Range")
+    private fun saveToGallery(inputStream: InputStream, displayName: String): Uri {
+        val values = ContentValues().apply {
+            put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
+            put(MediaStore.Images.Media.MIME_TYPE, "image/*")
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
+                put(MediaStore.Images.Media.IS_PENDING, 1)
+            }
+        }
+
+        val resolver = AppUtil.appContext.contentResolver
+        var uri: Uri? = null
+
+        try {
+            // 插入 MediaStore
+            uri = resolver.insert(
+                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
+                values
+            ) ?: throw IOException("Failed to create media entry")
+
+            // 写入文件内容
+            resolver.openOutputStream(uri)?.use { output ->
+                inputStream.copyTo(output)
+            } ?: throw IOException("Failed to get output stream")
+
+            // 适配 Android Q+
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                values.clear()
+                values.put(MediaStore.Images.Media.IS_PENDING, 0)
+                resolver.update(uri, values, null, null)
+            }
+
+            // 通知媒体扫描(兼容旧版本)
+            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+                val path = uri.path ?: throw IOException("Invalid uri path")
+                MediaScannerConnection.scanFile(
+                    AppUtil.appContext,
+                    arrayOf(path),
+                    arrayOf("image/*"),
+                    null
+                )
+            }
+
+            return uri
+        } catch (e: Exception) {
+            uri?.let { resolver.delete(it, null, null) }
+            throw e
+        }
+    }
+
+    // 从 Content Uri 获取文件名
+    @SuppressLint("Range")
+    private fun getFileNameFromContentUri(uri: Uri): String {
+        return AppUtil.appContext.contentResolver.query(
+            uri,
+            arrayOf(MediaStore.Images.ImageColumns.DISPLAY_NAME),
+            null,
+            null,
+            null
+        )?.use { cursor ->
+            if (cursor.moveToFirst()) {
+                cursor.getString(cursor.getColumnIndex(MediaStore.Images.ImageColumns.DISPLAY_NAME))
+            } else {
+                "image_${System.currentTimeMillis()}.jpg"
+            }
+        } ?: "image_${System.currentTimeMillis()}.jpg"
+    }
+}
+
+sealed class Resource<T>(
+    val data: T? = null,
+    val error: Throwable? = null
+) {
+    class Loading<T> : Resource<T>()
+
+    class Success<T>(data: T) : Resource<T>(data = data)
+
+    class Error<T>(error: Throwable) : Resource<T>(error = error)
+
+    companion object {
+        fun <T> loading(): Resource<T> = Loading()
+        fun <T> success(data: T): Resource<T> = Success(data)
+        fun <T> error(error: Throwable): Resource<T> = Error(error)
+    }
+}

+ 6 - 0
module/image/src/main/res/values-ar/strings.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="image_save_success">تم الحفظ بنجاح</string>
+    <string name="image_save_storage_permission_no_granted">لم يتم منح إذن التخزين، لا يمكن حفظ الصورة. انتقل إلى [إعدادات النظام] لتمكينه</string>
+    <string name="image_save_failed">فشل الحفظ، يرجى المحاولة مرة أخرى</string>
+</resources>

+ 6 - 0
module/image/src/main/res/values-zh/strings.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="image_save_success">Save successful</string>
+    <string name="image_save_storage_permission_no_granted">Storage permission not granted, unable to save image. Please enable it in [System Settings]</string>
+    <string name="image_save_failed">Save failed, please try again</string>
+</resources>

+ 0 - 1
module/image/src/main/res/values/ids.xml

@@ -1,5 +1,4 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
     <item name="image_preview_save" type="id"/>
-    <item name="image_preview_cancel" type="id"/>
 </resources>

+ 6 - 0
module/image/src/main/res/values/strings.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="image_save_success">保存成功</string>
+    <string name="image_save_storage_permission_no_granted">存储权限未获取,无法保存图片。到[系统设置]里去打开吧</string>
+    <string name="image_save_failed">保存失败,请重试</string>
+</resources>

+ 13 - 2
module/message/src/main/java/com/adealink/weparty/message/conversation/viewmodel/WeConversationViewModel.kt

@@ -188,8 +188,19 @@ class WeConversationViewModel : ConversationViewModel(), CoroutineScope {
             goImagePreviewActivity(act, arrayListOf(uiMessage.previewUrl.toString()))
             return
         }
-        val imageContent = uiMessage.content as? ImageMessage ?: return
-        val imageUrl = imageContent.localUri?.toString() ?: imageContent.remoteUri?.toString() ?: return
+        var imageUrl: String? = null
+        when(val imageContent = uiMessage.content) {
+            is GIFMessage -> {
+                imageUrl = imageContent.localUri?.toString() ?: imageContent.remoteUri?.toString() ?: return
+            }
+            is ImageMessage -> {
+                imageUrl = imageContent.localUri?.toString() ?: imageContent.remoteUri?.toString() ?: return
+            }
+            else -> {
+                //Ntd.
+            }
+        }
+        imageUrl ?: return
         goImagePreviewActivity(act, arrayListOf(imageUrl))
     }