diff --git a/app/src/main/java/io/legato/kazusa/ui/book/read/ReadBookActivity.kt b/app/src/main/java/io/legato/kazusa/ui/book/read/ReadBookActivity.kt index d3662db90..1f1679c4e 100644 --- a/app/src/main/java/io/legato/kazusa/ui/book/read/ReadBookActivity.kt +++ b/app/src/main/java/io/legato/kazusa/ui/book/read/ReadBookActivity.kt @@ -19,9 +19,6 @@ import androidx.activity.addCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu -import androidx.core.net.toUri -import androidx.core.view.doOnLayout -import androidx.core.view.doOnPreDraw import androidx.core.view.get import androidx.core.view.size import androidx.lifecycle.lifecycleScope @@ -30,7 +27,6 @@ import com.google.android.material.transition.platform.MaterialSharedAxis import com.jaredrummler.android.colorpicker.ColorPickerDialogListener import io.legato.kazusa.BuildConfig import io.legato.kazusa.R -import io.legato.kazusa.constant.AppConst import io.legato.kazusa.constant.AppLog import io.legato.kazusa.constant.BookType import io.legato.kazusa.constant.EventBus @@ -99,13 +95,11 @@ import io.legato.kazusa.ui.book.toc.TocActivityResult import io.legato.kazusa.ui.book.toc.rule.TxtTocRuleDialog import io.legato.kazusa.ui.browser.WebViewActivity import io.legato.kazusa.ui.dict.DictDialog -import io.legato.kazusa.ui.file.HandleFileContract import io.legato.kazusa.ui.login.SourceLoginActivity import io.legato.kazusa.ui.replace.ReplaceRuleActivity import io.legato.kazusa.ui.replace.edit.ReplaceEditActivity import io.legato.kazusa.ui.widget.PopupAction import io.legato.kazusa.ui.widget.dialog.PhotoDialog -import io.legato.kazusa.utils.ACache import io.legato.kazusa.utils.Debounce import io.legato.kazusa.utils.LogUtils import io.legato.kazusa.utils.NetworkUtils @@ -120,7 +114,6 @@ import io.legato.kazusa.utils.iconItemOnLongClick import io.legato.kazusa.utils.invisible import io.legato.kazusa.utils.isAbsUrl import io.legato.kazusa.utils.isTrue -import io.legato.kazusa.utils.launch import io.legato.kazusa.utils.navigationBarGravity import io.legato.kazusa.utils.observeEvent import io.legato.kazusa.utils.observeEventSticky @@ -212,12 +205,6 @@ class ReadBookActivity : BaseReadBookActivity(), ReadBook.loadOrUpContent() } } - private val selectImageDir = registerForActivityResult(HandleFileContract()) { - it.uri?.let { uri -> - ACache.get().put(AppConst.imagePathKey, uri.toString()) - viewModel.saveImage(it.value, uri) - } - } private var menu: Menu? = null private var backupJob: Job? = null private var tts: TTS? = null @@ -267,7 +254,7 @@ class ReadBookActivity : BaseReadBookActivity(), override fun onCreate(savedInstanceState: Bundle?) { if (AppConfig.sharedElementEnterTransitionEnable) { - postponeEnterTransition() + val transform = MaterialContainerTransform().apply { addTarget(binding.rootView) scrimColor = Color.TRANSPARENT @@ -275,30 +262,33 @@ class ReadBookActivity : BaseReadBookActivity(), } window.sharedElementEnterTransition = transform - window.sharedElementReturnTransition = - MaterialSharedAxis(MaterialSharedAxis.X, true).apply { - duration = 300 - } - if (AppConfig.delayBookLoadEnable) { window.sharedElementEnterTransition?.addListener(object : Transition.TransitionListener { override fun onTransitionStart(transition: Transition) {} override fun onTransitionEnd(transition: Transition) { - binding.readView.doOnLayout { + lifecycleScope.launch(Dispatchers.Default) { viewModel.initReadBookConfig(intent) viewModel.initData(intent) - binding.readView.initAfterTransition() - justInitData = true + + withContext(Main) { + binding.readView.initAfterTransition() + justInitData = true + } } transition.removeListener(this) } override fun onTransitionCancel(transition: Transition) { - viewModel.initReadBookConfig(intent) - viewModel.initData(intent) - binding.readView.initAfterTransition() - justInitData = true + lifecycleScope.launch(Dispatchers.Default) { + viewModel.initReadBookConfig(intent) + viewModel.initData(intent) + + withContext(Main) { + binding.readView.initAfterTransition() + justInitData = true + } + } transition.removeListener(this) } @@ -307,32 +297,21 @@ class ReadBookActivity : BaseReadBookActivity(), }) } } - - super.onCreate(savedInstanceState) - - if (!AppConfig.delayBookLoadEnable || !AppConfig.sharedElementEnterTransitionEnable) { - lifecycleScope.launch(Dispatchers.Default) { - viewModel.initReadBookConfig(intent) - viewModel.initData(intent) - - withContext(Main) { - binding.readView.initAfterTransition() - justInitData = true - } + window.sharedElementReturnTransition = + MaterialSharedAxis(MaterialSharedAxis.X, true).apply { + duration = 300 } - } - + super.onCreate(savedInstanceState) if (AppConfig.sharedElementEnterTransitionEnable) binding.rootView.transitionName = intent.getStringExtra("transitionName") + upScreenTimeOut() + ReadBook.register(this) + binding.cursorLeft.setOnTouchListener(this) binding.cursorRight.setOnTouchListener(this) - binding.rootView.doOnPreDraw { - startPostponedEnterTransition() - } - onBackPressedDispatcher.addCallback(this) { if (isShowingSearchResult) { exitSearchMenu() @@ -362,9 +341,19 @@ class ReadBookActivity : BaseReadBookActivity(), override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) - upScreenTimeOut() - ReadBook.register(this) + if (!AppConfig.delayBookLoadEnable || !AppConfig.sharedElementEnterTransitionEnable) { + lifecycleScope.launch(Dispatchers.Default) { + viewModel.initReadBookConfig(intent) + viewModel.initData(intent) + + withContext(Main) { + binding.readView.initAfterTransition() + justInitData = true + } + } + } } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) viewModel.initData(intent) @@ -1445,9 +1434,8 @@ class ReadBookActivity : BaseReadBookActivity(), listOf( SelectItem(getString(R.string.show), "show"), SelectItem(getString(R.string.refresh), "refresh"), - SelectItem(getString(R.string.action_save), "save"), + SelectItem("保存到相册", "save"), SelectItem(getString(R.string.menu), "menu"), - SelectItem(getString(R.string.select_folder), "selectFolder") ) ) popupAction.onActionClick = { @@ -1455,18 +1443,9 @@ class ReadBookActivity : BaseReadBookActivity(), "show" -> showDialogFragment(PhotoDialog(src)) "refresh" -> viewModel.refreshImage(src) "save" -> { - val path = ACache.get().getAsString(AppConst.imagePathKey) - if (path.isNullOrEmpty()) { - selectImageDir.launch { - value = src - } - } else { - viewModel.saveImage(src, path.toUri()) - } + viewModel.saveImage(src) } - "menu" -> showActionMenu() - "selectFolder" -> selectImageDir.launch() } popupAction.dismiss() } diff --git a/app/src/main/java/io/legato/kazusa/ui/book/read/ReadBookViewModel.kt b/app/src/main/java/io/legato/kazusa/ui/book/read/ReadBookViewModel.kt index 353ca4096..ba743ecc5 100644 --- a/app/src/main/java/io/legato/kazusa/ui/book/read/ReadBookViewModel.kt +++ b/app/src/main/java/io/legato/kazusa/ui/book/read/ReadBookViewModel.kt @@ -2,8 +2,6 @@ package io.legato.kazusa.ui.book.read import android.app.Application import android.content.Intent -import android.net.Uri -import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.MutableLiveData import io.legato.kazusa.R import io.legato.kazusa.base.BaseViewModel @@ -32,9 +30,7 @@ import io.legato.kazusa.model.webBook.WebBook import io.legato.kazusa.service.BaseReadAloudService import io.legato.kazusa.ui.book.read.page.entities.TextChapter import io.legato.kazusa.ui.book.searchContent.SearchResult -import io.legato.kazusa.utils.DocumentUtils -import io.legato.kazusa.utils.FileUtils -import io.legato.kazusa.utils.isContentScheme +import io.legato.kazusa.utils.ImageSaveUtils import io.legato.kazusa.utils.mapParallelSafe import io.legato.kazusa.utils.postEvent import io.legato.kazusa.utils.toStringArray @@ -48,10 +44,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEmpty import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.take -import java.io.File -import java.io.FileInputStream import java.io.FileNotFoundException -import java.io.FileOutputStream import kotlin.coroutines.coroutineContext /** @@ -504,30 +497,23 @@ class ReadBookViewModel(application: Application) : BaseViewModel(application) { /** * 保存图片 */ - fun saveImage(src: String?, uri: Uri) { + fun saveImage(src: String?) { src ?: return val book = ReadBook.book ?: return + execute { val image = BookHelp.getImage(book, src) - FileInputStream(image).use { input -> - if (uri.isContentScheme()) { - DocumentFile.fromTreeUri(context, uri)?.let { doc -> - val imageDoc = DocumentUtils.createFileIfNotExist(doc, image.name)!! - context.contentResolver.openOutputStream(imageDoc.uri)!!.use { output -> - input.copyTo(output) - } - } - } else { - val dir = File(uri.path ?: uri.toString()) - val file = FileUtils.createFileIfNotExist(dir, image.name) - FileOutputStream(file).use { output -> - input.copyTo(output) - } - } - } + val byteArray = image.readBytes() + val success = ImageSaveUtils.saveImageToGallery( + context, + byteArray, + folderName = "Legado" + ) + if (!success) throw NoStackTraceException("保存到相册失败") }.onError { - AppLog.put("保存图片出错\n${it.localizedMessage}", it) - context.toastOnUi("保存图片出错\n${it.localizedMessage}") + context.toastOnUi("保存图片失败: ${it.localizedMessage}") + }.onSuccess { + context.toastOnUi("已保存到相册") } } diff --git a/app/src/main/java/io/legato/kazusa/ui/browser/WebViewActivity.kt b/app/src/main/java/io/legato/kazusa/ui/browser/WebViewActivity.kt index 8899a0221..570cf8ebe 100644 --- a/app/src/main/java/io/legato/kazusa/ui/browser/WebViewActivity.kt +++ b/app/src/main/java/io/legato/kazusa/ui/browser/WebViewActivity.kt @@ -28,11 +28,10 @@ import io.legato.kazusa.help.http.CookieStore import io.legato.kazusa.help.source.SourceVerificationHelp import io.legato.kazusa.lib.dialogs.SelectItem import io.legato.kazusa.lib.dialogs.alert +import io.legato.kazusa.lib.dialogs.selector //import io.legado.app.lib.theme.accentColor import io.legato.kazusa.model.Download import io.legato.kazusa.ui.association.OnLineImportActivity -import io.legato.kazusa.ui.file.HandleFileContract -import io.legato.kazusa.utils.ACache import io.legato.kazusa.utils.gone import io.legato.kazusa.utils.invisible import io.legato.kazusa.utils.keepScreenOn @@ -51,17 +50,10 @@ class WebViewActivity : VMBaseActivity() { override val binding by viewBinding(ActivityWebViewBinding::inflate) override val viewModel by viewModels() - private val imagePathKey = "imagePath" private var customWebViewCallback: WebChromeClient.CustomViewCallback? = null private var webPic: String? = null private var isCloudflareChallenge = false private var isFullScreen = false - private val saveImage = registerForActivityResult(HandleFileContract()) { - it.uri?.let { uri -> - ACache.get().put(imagePathKey, uri.toString()) - viewModel.saveImage(webPic, uri.toString()) - } - } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -183,9 +175,17 @@ class WebViewActivity : VMBaseActivity() { if (hitTestResult.type == WebView.HitTestResult.IMAGE_TYPE || hitTestResult.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE ) { - hitTestResult.extra?.let { - saveImage(it) - return@setOnLongClickListener true + hitTestResult.extra?.let { webPic -> + selector( + arrayListOf( + SelectItem(getString(R.string.action_save), "save"), + ) + ) { _, charSequence, _ -> + when (charSequence.value) { + "save" -> saveImage(webPic) + } + } + return@setOnLongClickListener false } } return@setOnLongClickListener false @@ -201,23 +201,7 @@ class WebViewActivity : VMBaseActivity() { private fun saveImage(webPic: String) { this.webPic = webPic - val path = ACache.get().getAsString(imagePathKey) - if (path.isNullOrEmpty()) { - selectSaveFolder() - } else { - viewModel.saveImage(webPic, path) - } - } - - private fun selectSaveFolder() { - val default = arrayListOf>() - val path = ACache.get().getAsString(imagePathKey) - if (!path.isNullOrEmpty()) { - default.add(SelectItem(path, -1)) - } - saveImage.launch { - otherActions = default - } + viewModel.saveImage(webPic) } override fun finish() { diff --git a/app/src/main/java/io/legato/kazusa/ui/browser/WebViewModel.kt b/app/src/main/java/io/legato/kazusa/ui/browser/WebViewModel.kt index bfb4f83ab..6612287e9 100644 --- a/app/src/main/java/io/legato/kazusa/ui/browser/WebViewModel.kt +++ b/app/src/main/java/io/legato/kazusa/ui/browser/WebViewModel.kt @@ -2,13 +2,11 @@ package io.legato.kazusa.ui.browser import android.app.Application import android.content.Intent -import android.net.Uri import android.util.Base64 import android.webkit.URLUtil import android.webkit.WebView -import androidx.documentfile.provider.DocumentFile import io.legato.kazusa.base.BaseViewModel -import io.legato.kazusa.constant.AppConst +import io.legato.kazusa.constant.AppConst.imagePathKey import io.legato.kazusa.constant.SourceType import io.legato.kazusa.data.appDb import io.legato.kazusa.exception.NoStackTraceException @@ -17,15 +15,11 @@ import io.legato.kazusa.help.http.okHttpClient import io.legato.kazusa.help.source.SourceHelp import io.legato.kazusa.help.source.SourceVerificationHelp import io.legato.kazusa.model.analyzeRule.AnalyzeUrl -import io.legato.kazusa.utils.DocumentUtils -import io.legato.kazusa.utils.FileUtils -import io.legato.kazusa.utils.isContentScheme +import io.legato.kazusa.utils.ACache +import io.legato.kazusa.utils.ImageSaveUtils import io.legato.kazusa.utils.printOnDebug import io.legato.kazusa.utils.toastOnUi -import io.legato.kazusa.utils.writeBytes import org.apache.commons.text.StringEscapeUtils -import java.io.File -import java.util.Date class WebViewModel(application: Application) : BaseViewModel(application) { var intent: Intent? = null @@ -66,26 +60,23 @@ class WebViewModel(application: Application) : BaseViewModel(application) { } } - fun saveImage(webPic: String?, path: String) { + fun saveImage(webPic: String?) { webPic ?: return execute { - val fileName = "${AppConst.fileNameFormat.format(Date(System.currentTimeMillis()))}.jpg" - webData2bitmap(webPic)?.let { biteArray -> - if (path.isContentScheme()) { - val uri = Uri.parse(path) - DocumentFile.fromTreeUri(context, uri)?.let { doc -> - DocumentUtils.createFileIfNotExist(doc, fileName) - ?.writeBytes(context, biteArray) - } - } else { - val file = FileUtils.createFileIfNotExist(File(path), fileName) - file.writeBytes(biteArray) - } - } ?: throw Throwable("NULL") + val byteArray = webData2bitmap(webPic) ?: throw Throwable("NULL") + + val success = ImageSaveUtils.saveImageToGallery( + context, + byteArray, + folderName = "Legado" + ) + + if (!success) throw Throwable("保存到相册失败") }.onError { - context.toastOnUi("保存图片失败:${it.localizedMessage}") + ACache.get().remove(imagePathKey) + context.toastOnUi("保存图片失败: ${it.localizedMessage}") }.onSuccess { - context.toastOnUi("保存成功") + context.toastOnUi("已保存到相册") } } diff --git a/app/src/main/java/io/legato/kazusa/ui/rss/read/ReadRssActivity.kt b/app/src/main/java/io/legato/kazusa/ui/rss/read/ReadRssActivity.kt index d5a91711e..c1cb96574 100644 --- a/app/src/main/java/io/legato/kazusa/ui/rss/read/ReadRssActivity.kt +++ b/app/src/main/java/io/legato/kazusa/ui/rss/read/ReadRssActivity.kt @@ -30,7 +30,6 @@ import com.script.rhino.runScriptWithContext import io.legato.kazusa.R import io.legato.kazusa.base.VMBaseActivity import io.legato.kazusa.constant.AppConst -import io.legato.kazusa.constant.AppConst.imagePathKey import io.legato.kazusa.constant.AppLog import io.legato.kazusa.data.entities.RssSource import io.legato.kazusa.databinding.ActivityRssReadBinding @@ -40,10 +39,8 @@ import io.legato.kazusa.lib.dialogs.SelectItem import io.legato.kazusa.lib.dialogs.selector import io.legato.kazusa.model.Download import io.legato.kazusa.ui.association.OnLineImportActivity -import io.legato.kazusa.ui.file.HandleFileContract import io.legato.kazusa.ui.login.SourceLoginActivity import io.legato.kazusa.ui.rss.favorites.RssFavoritesDialog -import io.legato.kazusa.utils.ACache import io.legato.kazusa.utils.NetworkUtils import io.legato.kazusa.utils.gone import io.legato.kazusa.utils.invisible @@ -83,12 +80,6 @@ class ReadRssActivity : VMBaseActivity private var ttsMenuItem: MenuItem? = null private var isFullScreen = false private var customWebViewCallback: WebChromeClient.CustomViewCallback? = null - private val selectImageDir = registerForActivityResult(HandleFileContract()) { - it.uri?.let { uri -> - ACache.get().put(imagePathKey, uri.toString()) - viewModel.saveImage(it.value, uri) - } - } private val rssJsExtensions by lazy { RssJsExtensions(this) } fun getSource(): RssSource? { @@ -257,12 +248,10 @@ class ReadRssActivity : VMBaseActivity selector( arrayListOf( SelectItem(getString(R.string.action_save), "save"), - SelectItem(getString(R.string.select_folder), "selectFolder") ) ) { _, charSequence, _ -> when (charSequence.value) { "save" -> saveImage(webPic) - "selectFolder" -> selectSaveFolder(null) } } return@setOnLongClickListener true @@ -281,24 +270,7 @@ class ReadRssActivity : VMBaseActivity } private fun saveImage(webPic: String) { - val path = ACache.get().getAsString(imagePathKey) - if (path.isNullOrEmpty()) { - selectSaveFolder(webPic) - } else { - viewModel.saveImage(webPic, path.toUri()) - } - } - - private fun selectSaveFolder(webPic: String?) { - val default = arrayListOf>() - val path = ACache.get().getAsString(imagePathKey) - if (!path.isNullOrEmpty()) { - default.add(SelectItem(path, -1)) - } - selectImageDir.launch { - otherActions = default - value = webPic - } + viewModel.saveImage(webPic) } @SuppressLint("SetJavaScriptEnabled") diff --git a/app/src/main/java/io/legato/kazusa/ui/rss/read/ReadRssViewModel.kt b/app/src/main/java/io/legato/kazusa/ui/rss/read/ReadRssViewModel.kt index d6d245cb4..e97b7b4c4 100644 --- a/app/src/main/java/io/legato/kazusa/ui/rss/read/ReadRssViewModel.kt +++ b/app/src/main/java/io/legato/kazusa/ui/rss/read/ReadRssViewModel.kt @@ -2,14 +2,12 @@ package io.legato.kazusa.ui.rss.read import android.app.Application import android.content.Intent -import android.net.Uri import android.util.Base64 import android.webkit.URLUtil import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.script.rhino.runScriptWithContext import io.legato.kazusa.base.BaseViewModel -import io.legato.kazusa.constant.AppConst import io.legato.kazusa.data.appDb import io.legato.kazusa.data.entities.RssArticle import io.legato.kazusa.data.entities.RssSource @@ -20,11 +18,10 @@ import io.legato.kazusa.help.http.newCallResponseBody import io.legato.kazusa.help.http.okHttpClient import io.legato.kazusa.model.analyzeRule.AnalyzeUrl import io.legato.kazusa.model.rss.Rss +import io.legato.kazusa.utils.ImageSaveUtils import io.legato.kazusa.utils.toastOnUi -import io.legato.kazusa.utils.writeBytes import kotlinx.coroutines.Dispatchers.IO import splitties.init.appCtx -import java.util.Date import kotlin.coroutines.coroutineContext @@ -170,19 +167,25 @@ class ReadRssViewModel(application: Application) : BaseViewModel(application) { } } - fun saveImage(webPic: String?, uri: Uri) { + fun saveImage(webPic: String?) { webPic ?: return execute { - val fileName = "${AppConst.fileNameFormat.format(Date(System.currentTimeMillis()))}.jpg" val byteArray = webData2bitmap(webPic) ?: throw NoStackTraceException("NULL") - uri.writeBytes(context, fileName, byteArray) + + val success = ImageSaveUtils.saveImageToGallery( + context, + byteArray, + folderName = "Legado" // 可以自定义相册子目录 + ) + if (!success) throw NoStackTraceException("保存到相册失败") }.onError { - context.toastOnUi("保存图片失败:${it.localizedMessage}") + context.toastOnUi("保存图片失败: ${it.localizedMessage}") }.onSuccess { - context.toastOnUi("保存成功") + context.toastOnUi("已保存到相册") } } + private suspend fun webData2bitmap(data: String): ByteArray? { return if (URLUtil.isValidUrl(data)) { okHttpClient.newCallResponseBody { diff --git a/app/src/main/java/io/legato/kazusa/utils/ImageSaveUtils.kt b/app/src/main/java/io/legato/kazusa/utils/ImageSaveUtils.kt new file mode 100644 index 000000000..a7d20e5ef --- /dev/null +++ b/app/src/main/java/io/legato/kazusa/utils/ImageSaveUtils.kt @@ -0,0 +1,58 @@ +package io.legato.kazusa.utils + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.provider.MediaStore +import io.legato.kazusa.constant.AppConst +import java.util.Date + +object ImageSaveUtils { + + /** + * 使用 MediaStore 保存图片到系统相册 + * @param context Context + * @param byteArray 图片数据(JPG/PNG 的 byte[]) + * @param prefix 文件名前缀,默认 "IMG_" + * @param folderName 相册下的子文件夹,默认 "Legado" + */ + fun saveImageToGallery( + context: Context, + byteArray: ByteArray, + prefix: String = "IMG_", + folderName: String = "Legado" + ): Boolean { + return try { + val fileName = prefix + AppConst.fileNameFormat.format(Date()) + ".jpg" + + val contentValues = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, fileName) + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Android10以上指定相对路径 + put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/$folderName") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + } + + val resolver = context.contentResolver + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) + ?: return false + + resolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(byteArray) + } + + 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) + } + + true + } catch (e: Exception) { + e.printStackTrace() + false + } + } +}