mirror of https://github.com/Ashinch/ReadYou.git
feat(ui): save image as file (#627)
This commit is contained in:
parent
cad1143686
commit
37835a4964
|
@ -4,7 +4,8 @@
|
|||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
|
|
@ -17,6 +17,7 @@ import me.ash.reader.infrastructure.di.IODispatcher
|
|||
import me.ash.reader.infrastructure.net.NetworkDataSource
|
||||
import me.ash.reader.infrastructure.rss.OPMLDataSource
|
||||
import me.ash.reader.infrastructure.rss.RssHelper
|
||||
import me.ash.reader.infrastructure.storage.AndroidImageDownloader
|
||||
import me.ash.reader.ui.ext.del
|
||||
import me.ash.reader.ui.ext.getLatestApk
|
||||
import me.ash.reader.ui.ext.isGitHub
|
||||
|
@ -92,6 +93,9 @@ class AndroidApp : Application(), Configuration.Provider {
|
|||
@Inject
|
||||
lateinit var imageLoader: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var imageDownloader: AndroidImageDownloader
|
||||
|
||||
/**
|
||||
* When the application startup.
|
||||
*
|
||||
|
|
|
@ -0,0 +1,114 @@
|
|||
package me.ash.reader.infrastructure.storage
|
||||
|
||||
import android.content.Context
|
||||
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.annotation.CheckResult
|
||||
import androidx.annotation.DeprecatedSinceApi
|
||||
import androidx.core.content.contentValuesOf
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.ash.reader.R
|
||||
import me.ash.reader.infrastructure.di.IODispatcher
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import kotlin.io.path.Path
|
||||
import kotlin.io.path.createFile
|
||||
import kotlin.io.path.createParentDirectories
|
||||
|
||||
private const val TAG = "AndroidImageDownloader"
|
||||
|
||||
class AndroidImageDownloader @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
@CheckResult
|
||||
suspend fun downloadImage(imageUrl: String): Result<Uri> {
|
||||
return withContext(ioDispatcher) {
|
||||
Request.Builder().url(imageUrl).build().runCatching {
|
||||
okHttpClient.newCall(this).execute().run {
|
||||
|
||||
val fileName = URLUtil.guessFileName(
|
||||
imageUrl, header("Content-Disposition"), body.contentType()?.toString()
|
||||
)
|
||||
|
||||
val relativePath =
|
||||
Environment.DIRECTORY_PICTURES + "/" + context.getString(R.string.read_you)
|
||||
|
||||
val resolver = context.contentResolver
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val imageCollection =
|
||||
MediaStore.Images.Media.getContentUri(
|
||||
MediaStore.VOLUME_EXTERNAL_PRIMARY
|
||||
)
|
||||
|
||||
|
||||
val imageDetails = contentValuesOf(
|
||||
MediaStore.Images.Media.DISPLAY_NAME to fileName,
|
||||
MediaStore.Images.Media.RELATIVE_PATH to relativePath,
|
||||
MediaStore.Images.Media.IS_PENDING to 1
|
||||
)
|
||||
|
||||
val imageUri = resolver.insert(imageCollection, imageDetails)
|
||||
?: return@withContext Result.failure(IOException("Cannot create image"))
|
||||
|
||||
resolver.openFileDescriptor(imageUri, "w", null).use { pfd ->
|
||||
body.byteStream().use {
|
||||
it.copyTo(
|
||||
FileOutputStream(
|
||||
pfd?.fileDescriptor ?: return@withContext Result.failure(
|
||||
IOException("Null fd")
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
imageDetails.run {
|
||||
clear()
|
||||
put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||
resolver.update(imageUri, this, null, null)
|
||||
}
|
||||
imageUri
|
||||
} else {
|
||||
saveImageForAndroidP(
|
||||
fileName,
|
||||
Environment.getExternalStoragePublicDirectory(relativePath).path
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@DeprecatedSinceApi(29)
|
||||
private fun Response.saveImageForAndroidP(
|
||||
fileName: String,
|
||||
imageDirectory: String,
|
||||
): Uri {
|
||||
val file = Path(imageDirectory, fileName).createParentDirectories().createFile().toFile()
|
||||
|
||||
body.byteStream().use {
|
||||
it.copyTo(file.outputStream())
|
||||
}
|
||||
|
||||
var contentUri: Uri = Uri.fromFile(file)
|
||||
|
||||
MediaScannerConnection.scanFile(context, arrayOf(file.path), null) { _, uri ->
|
||||
contentUri = uri
|
||||
}
|
||||
|
||||
return contentUri
|
||||
}
|
||||
}
|
|
@ -1,5 +1,10 @@
|
|||
package me.ash.reader.ui.page.home.reading
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
|
@ -9,24 +14,36 @@ import androidx.compose.foundation.layout.systemBars
|
|||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.HapticFeedbackConstantsCompat
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
|
||||
import me.ash.reader.R
|
||||
import me.ash.reader.ui.component.base.RYAsyncImage
|
||||
import me.ash.reader.ui.ext.showToast
|
||||
import me.saket.telephoto.zoomable.ZoomSpec
|
||||
import me.saket.telephoto.zoomable.ZoomableContentLocation
|
||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||
|
@ -35,7 +52,9 @@ import me.saket.telephoto.zoomable.zoomable
|
|||
data class ImageData(val imageUrl: String = "", val altText: String = "")
|
||||
|
||||
@Composable
|
||||
fun ReaderImageViewer(imageData: ImageData, onDismissRequest: () -> Unit = {}) {
|
||||
fun ReaderImageViewer(
|
||||
imageData: ImageData, onDownloadImage: (String) -> Unit, onDismissRequest: () -> Unit = {}
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
|
@ -43,14 +62,15 @@ fun ReaderImageViewer(imageData: ImageData, onDismissRequest: () -> Unit = {}) {
|
|||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// .background(Color.Black)
|
||||
.windowInsetsPadding(WindowInsets.systemBars)
|
||||
) {
|
||||
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
|
||||
val view = LocalView.current
|
||||
val context = LocalContext.current
|
||||
|
||||
val dialogWindowProvider = view.parent as? DialogWindowProvider
|
||||
dialogWindowProvider?.window?.setDimAmount(1f)
|
||||
|
||||
val zoomableState =
|
||||
rememberZoomableState(zoomSpec = ZoomSpec(maxZoomFactor = 4f))
|
||||
val zoomableState = rememberZoomableState(zoomSpec = ZoomSpec(maxZoomFactor = 4f))
|
||||
|
||||
val painter = rememberAsyncImagePainter(model = imageData.imageUrl)
|
||||
|
||||
|
@ -60,8 +80,6 @@ fun ReaderImageViewer(imageData: ImageData, onDismissRequest: () -> Unit = {}) {
|
|||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
Image(
|
||||
painter = painter,
|
||||
contentDescription = imageData.altText,
|
||||
|
@ -73,18 +91,66 @@ fun ReaderImageViewer(imageData: ImageData, onDismissRequest: () -> Unit = {}) {
|
|||
)
|
||||
|
||||
IconButton(
|
||||
onClick = onDismissRequest,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = Color.Gray.copy(alpha = 0.5f),
|
||||
contentColor = Color.White
|
||||
),
|
||||
modifier = Modifier.padding(12.dp)
|
||||
onClick = {
|
||||
view.performHapticFeedback(HapticFeedbackConstantsCompat.KEYBOARD_TAP)
|
||||
onDismissRequest()
|
||||
}, colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.5f), contentColor = Color.White
|
||||
), modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.align(Alignment.TopStart)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
contentDescription = stringResource(id = R.string.close)
|
||||
contentDescription = stringResource(id = R.string.close),
|
||||
)
|
||||
}
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
IconButton(
|
||||
onClick = { expanded = true }, colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.5f), contentColor = Color.White
|
||||
), modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.MoreHoriz,
|
||||
contentDescription = stringResource(id = R.string.more),
|
||||
)
|
||||
}
|
||||
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(),
|
||||
onResult = { result ->
|
||||
if (result) {
|
||||
onDownloadImage(imageData.imageUrl)
|
||||
} else {
|
||||
context.showToast(context.getString(R.string.permission_denied))
|
||||
}
|
||||
})
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(text = { Text(text = stringResource(id = R.string.save)) },
|
||||
onClick = {
|
||||
val isStoragePermissionGranted = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
if (Build.VERSION.SDK_INT > 28 || isStoragePermissionGranted) {
|
||||
onDownloadImage(imageData.imageUrl)
|
||||
} else {
|
||||
launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
}
|
||||
expanded = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -28,10 +28,12 @@ import androidx.compose.ui.platform.LocalHapticFeedback
|
|||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import me.ash.reader.R
|
||||
import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle
|
||||
import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar
|
||||
import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation
|
||||
import me.ash.reader.ui.ext.collectAsStateValue
|
||||
import me.ash.reader.ui.ext.showToast
|
||||
import me.ash.reader.ui.motion.materialSharedAxisY
|
||||
import me.ash.reader.ui.page.home.HomeViewModel
|
||||
import kotlin.math.abs
|
||||
|
@ -40,7 +42,9 @@ import kotlin.math.abs
|
|||
private const val UPWARD = 1
|
||||
private const val DOWNWARD = -1
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class)
|
||||
@OptIn(
|
||||
ExperimentalFoundationApi::class, ExperimentalMaterialApi::class
|
||||
)
|
||||
@Composable
|
||||
fun ReadingPage(
|
||||
navController: NavHostController,
|
||||
|
@ -48,6 +52,7 @@ fun ReadingPage(
|
|||
readingViewModel: ReadingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val tonalElevation = LocalReadingPageTonalElevation.current
|
||||
val context = LocalContext.current
|
||||
val isPullToSwitchArticleEnabled = LocalPullToSwitchArticle.current.value
|
||||
val readingUiState = readingViewModel.readingUiState.collectAsStateValue()
|
||||
val readerState = readingViewModel.readerStateStateFlow.collectAsStateValue()
|
||||
|
@ -229,6 +234,20 @@ fun ReadingPage(
|
|||
}
|
||||
)
|
||||
if (showFullScreenImageViewer) {
|
||||
ReaderImageViewer(imageData = currentImageData) { showFullScreenImageViewer = false }
|
||||
|
||||
ReaderImageViewer(
|
||||
imageData = currentImageData,
|
||||
onDownloadImage = {
|
||||
readingViewModel.downloadImage(
|
||||
it,
|
||||
onSuccess = { context.showToast(context.getString(R.string.image_saved)) },
|
||||
onFailure = {
|
||||
// FIXME: crash the app for error report
|
||||
th -> throw th
|
||||
}
|
||||
)
|
||||
},
|
||||
onDismissRequest = { showFullScreenImageViewer = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package me.ash.reader.ui.page.home.reading
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
@ -20,6 +21,7 @@ import me.ash.reader.domain.service.RssService
|
|||
import me.ash.reader.infrastructure.di.ApplicationScope
|
||||
import me.ash.reader.infrastructure.di.IODispatcher
|
||||
import me.ash.reader.infrastructure.rss.RssHelper
|
||||
import me.ash.reader.infrastructure.storage.AndroidImageDownloader
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -31,6 +33,7 @@ class ReadingViewModel @Inject constructor(
|
|||
private val ioDispatcher: CoroutineDispatcher,
|
||||
@ApplicationScope
|
||||
private val applicationScope: CoroutineScope,
|
||||
private val imageDownloader: AndroidImageDownloader
|
||||
) : ViewModel() {
|
||||
|
||||
private val _readingUiState = MutableStateFlow(ReadingUiState())
|
||||
|
@ -195,6 +198,16 @@ class ReadingViewModel @Inject constructor(
|
|||
} ?: return false
|
||||
return true
|
||||
}
|
||||
|
||||
fun downloadImage(
|
||||
url: String,
|
||||
onSuccess: (Uri) -> Unit = {},
|
||||
onFailure: (Throwable) -> Unit = {}
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
imageDownloader.downloadImage(url).onSuccess(onSuccess).onFailure(onFailure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ReadingUiState(
|
||||
|
|
|
@ -421,4 +421,7 @@
|
|||
<string name="toggle_starred">Toggle starred</string>
|
||||
<string name="export">Export</string>
|
||||
<string name="pull_to_switch_article">Pull to switch article</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="image_saved">Image saved</string>
|
||||
<string name="permission_denied">Permission denied</string>
|
||||
</resources>
|
||||
|
|
Loading…
Reference in New Issue