|
|
@@ -0,0 +1,274 @@
|
|
|
+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.adealink.weparty.imageselect.util.PictureMimeType
|
|
|
+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,
|
|
|
+ "image",
|
|
|
+ "image/*"
|
|
|
+ )
|
|
|
+ imageUri.startsWith("file://") -> handleFileUri(imageUri, "image/*")
|
|
|
+ else -> throw IllegalArgumentException("Unsupported uri type")
|
|
|
+ }
|
|
|
+ }
|
|
|
+ saveResult.postValue(Resource.success(savedUri))
|
|
|
+ } catch (e: Exception) {
|
|
|
+ saveResult.postValue(Resource.error(e))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ fun saveLocalVideoToAlbum(videoLocalUrl: String?) {
|
|
|
+ viewModelScope.launch {
|
|
|
+ if (videoLocalUrl == null) {
|
|
|
+ saveResult.postValue(Resource.error(IOException("Video local url is null")))
|
|
|
+ return@launch
|
|
|
+ }
|
|
|
+ saveResult.postValue(Resource.loading())
|
|
|
+ try {
|
|
|
+ val savedUri = withContext(Dispatcher.WENEXT_THREAD_POOL) {
|
|
|
+ when {
|
|
|
+ videoLocalUrl.startsWith("content://") -> handleContentUri(
|
|
|
+ videoLocalUrl,
|
|
|
+ "video",
|
|
|
+ "video/*"
|
|
|
+ )
|
|
|
+ else -> handleFileUri(videoLocalUrl, "video/*")
|
|
|
+ }
|
|
|
+ }
|
|
|
+ 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, "image/*")
|
|
|
+ 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, namePrefix: String, mineType: String): Uri {
|
|
|
+ val uri = Uri.parse(uriString)
|
|
|
+ return AppUtil.appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
|
|
|
+ saveToGallery(
|
|
|
+ inputStream = inputStream,
|
|
|
+ displayName = getFileNameFromContentUri(uri, namePrefix),
|
|
|
+ mineType
|
|
|
+ )
|
|
|
+ } ?: throw FileNotFoundException("Cannot open content uri")
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理文件路径
|
|
|
+ private fun handleFileUri(uriString: String, mineType: String): Uri {
|
|
|
+ val file = File(uriString.replace("file://", ""))
|
|
|
+ if (!file.exists()) throw FileNotFoundException("File not exists")
|
|
|
+
|
|
|
+ return saveToGallery(
|
|
|
+ inputStream = FileInputStream(file),
|
|
|
+ displayName = file.name,
|
|
|
+ mineType
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ // 核心保存方法(适配 Android 10+)
|
|
|
+ @SuppressLint("Range")
|
|
|
+ private fun saveToGallery(
|
|
|
+ inputStream: InputStream,
|
|
|
+ displayName: String,
|
|
|
+ mineType: String
|
|
|
+ ): Uri {
|
|
|
+ val values =
|
|
|
+ if (PictureMimeType.eqImage(mineType)) {
|
|
|
+ ContentValues().apply {
|
|
|
+ put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
|
|
|
+ put(MediaStore.Images.Media.MIME_TYPE, mineType)
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ ContentValues().apply {
|
|
|
+ put(MediaStore.Video.Media.DISPLAY_NAME, displayName)
|
|
|
+ put(MediaStore.Video.Media.MIME_TYPE, "video/*")
|
|
|
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
|
+ put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES)
|
|
|
+ put(MediaStore.Video.Media.IS_PENDING, 1)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ val resolver = AppUtil.appContext.contentResolver
|
|
|
+ var uri: Uri? = null
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 插入 MediaStore
|
|
|
+ uri = resolver.insert(
|
|
|
+ if (PictureMimeType.eqImage(mineType)) {
|
|
|
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
|
|
+ } else {
|
|
|
+ MediaStore.Video.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()
|
|
|
+ if (PictureMimeType.eqImage(mineType)) {
|
|
|
+ values.put(MediaStore.Images.Media.IS_PENDING, 0)
|
|
|
+ } else {
|
|
|
+ values.put(MediaStore.Video.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(mineType),
|
|
|
+ null
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ return uri
|
|
|
+ } catch (e: Exception) {
|
|
|
+ uri?.let { resolver.delete(it, null, null) }
|
|
|
+ throw e
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 从 Content Uri 获取文件名
|
|
|
+ @SuppressLint("Range")
|
|
|
+ private fun getFileNameFromContentUri(uri: Uri, namePrefix: String): 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 {
|
|
|
+ "${namePrefix}_${System.currentTimeMillis()}.jpg"
|
|
|
+ }
|
|
|
+ } ?: "${namePrefix}_${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)
|
|
|
+ }
|
|
|
+}
|