feat(common-ui): add save image to gallery (#26)

This commit is contained in:
Diego Beraldin 2023-09-18 19:31:39 +02:00 committed by GitHub
parent 008838b5ca
commit 88b54c2263
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 215 additions and 4 deletions

View File

@ -20,4 +20,4 @@
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
</manifest>

View File

@ -0,0 +1,28 @@
package com.github.diegoberaldin.raccoonforlemmy.core.commonui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@Composable
fun ProgressHud(
overlayColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f),
color: Color = MaterialTheme.colorScheme.secondary,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(overlayColor),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = color,
)
}
}

View File

@ -103,6 +103,7 @@ val commonUiModule = module {
ZoomableImageViewModel(
mvi = DefaultMviModel(ZoomableImageMviModel.UiState()),
shareHelper = get(),
galleryHelper = get(),
)
}
}

View File

@ -6,6 +6,7 @@ interface ZoomableImageMviModel :
MviModel<ZoomableImageMviModel.Intent, ZoomableImageMviModel.UiState, ZoomableImageMviModel.Effect> {
sealed interface Intent {
data class Share(val url: String) : Intent
data class SaveToGallery(val url: String) : Intent
}
data class UiState(val loading: Boolean = false)

View File

@ -2,10 +2,13 @@ package com.github.diegoberaldin.raccoonforlemmy.core.commonui.image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@ -13,6 +16,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -20,7 +25,9 @@ import androidx.compose.ui.graphics.Color
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import com.github.diegoberaldin.racconforlemmy.core.utils.onClick
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.bindToLifecycle
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.ProgressHud
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.ZoomableImage
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.di.getNavigationCoordinator
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.di.getZoomableImageViewModel
@ -32,9 +39,9 @@ class ZoomableImageScreen(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val model = rememberScreenModel { getZoomableImageViewModel() }
model.bindToLifecycle(key)
val uiState by model.uiState.collectAsState()
val navigator = remember { getNavigationCoordinator().getRootNavigator() }
Scaffold(
@ -52,6 +59,15 @@ class ZoomableImageScreen(
)
},
actions = {
Icon(
modifier = Modifier.onClick {
model.reduce(ZoomableImageMviModel.Intent.SaveToGallery(url))
},
imageVector = Icons.Default.Download,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.width(Spacing.s))
Icon(
modifier = Modifier.onClick {
model.reduce(ZoomableImageMviModel.Intent.Share(url))
@ -78,5 +94,9 @@ class ZoomableImageScreen(
}
)
if (uiState.loading) {
ProgressHud()
}
}
}

View File

@ -1,13 +1,20 @@
package com.github.diegoberaldin.raccoonforlemmy.core.commonui.image
import cafe.adriel.voyager.core.model.ScreenModel
import com.github.diegoberaldin.racconforlemmy.core.utils.DateTime
import com.github.diegoberaldin.racconforlemmy.core.utils.GalleryHelper
import com.github.diegoberaldin.racconforlemmy.core.utils.ShareHelper
import com.github.diegoberaldin.racconforlemmy.core.utils.download
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.DefaultMviModel
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.MviModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
class ZoomableImageViewModel(
private val mvi: DefaultMviModel<ZoomableImageMviModel.Intent, ZoomableImageMviModel.UiState, ZoomableImageMviModel.Effect>,
private val shareHelper: ShareHelper,
private val galleryHelper: GalleryHelper,
) : ScreenModel,
MviModel<ZoomableImageMviModel.Intent, ZoomableImageMviModel.UiState, ZoomableImageMviModel.Effect> by mvi {
@ -16,6 +23,26 @@ class ZoomableImageViewModel(
is ZoomableImageMviModel.Intent.Share -> {
shareHelper.share(intent.url, "image/*")
}
is ZoomableImageMviModel.Intent.SaveToGallery -> downloadAndSave(intent.url)
}
}
private fun downloadAndSave(url: String) {
mvi.scope?.launch(Dispatchers.IO) {
mvi.updateState { it.copy(loading = true) }
try {
val bytes = galleryHelper.download(url)
val extension = url.let { s ->
val idx = s.lastIndexOf(".").takeIf { it >= 0 } ?: s.length
s.substring(idx).takeIf { it.isNotEmpty() } ?: ".jpeg"
}
galleryHelper.saveToGallery(bytes, "${DateTime.epochMillis()}.$extension")
} catch (e: Throwable) {
e.printStackTrace()
} finally {
mvi.updateState { it.copy(loading = false) }
}
}
}
}

View File

@ -40,6 +40,7 @@ kotlin {
implementation(compose.components.resources)
implementation(libs.koin.core)
implementation(libs.ktor.cio)
implementation(projects.resources)
}
}

View File

@ -6,6 +6,8 @@ import java.time.format.DateTimeFormatter
import java.util.GregorianCalendar
actual object DateTime {
actual fun epochMillis(): Long = System.currentTimeMillis()
actual fun getFormattedDate(
iso8601Timestamp: String,
format: String,

View File

@ -0,0 +1,49 @@
package com.github.diegoberaldin.racconforlemmy.core.utils
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import org.koin.dsl.module
class DefaultGalleryHelper(
private val context: Context,
) : GalleryHelper {
override fun saveToGallery(bytes: ByteArray, name: String) {
val resolver = context.applicationContext.contentResolver
val collection =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
} else {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
val details = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, name)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = resolver.insert(collection, details)
if (uri != null) {
resolver.openFileDescriptor(uri, "w", null).use { pfd ->
ParcelFileDescriptor.AutoCloseOutputStream(pfd).use {
it.write(bytes)
}
}
details.clear()
details.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, details, null, null)
}
}
}
actual val galleryHelperModule = module {
single<GalleryHelper> {
DefaultGalleryHelper(
context = get()
)
}
}

View File

@ -1,6 +1,8 @@
package com.github.diegoberaldin.racconforlemmy.core.utils
expect object DateTime {
fun epochMillis(): Long
fun getFormattedDate(
iso8601Timestamp: String,
format: String,

View File

@ -10,6 +10,17 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import com.github.diegoberaldin.raccoonforlemmy.resources.MR
import dev.icerock.moko.resources.compose.stringResource
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.prepareGet
import io.ktor.http.contentLength
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.core.isEmpty
import io.ktor.utils.io.core.readBytes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
fun Modifier.onClick(onClick: () -> Unit): Modifier = composed {
@ -47,3 +58,20 @@ fun Int.getPrettyNumber(
else -> this.toString()
}
}
suspend fun GalleryHelper.download(url: String): ByteArray = withContext(Dispatchers.IO) {
val client = HttpClient(CIO)
client.prepareGet(url).execute { httpResponse ->
val channel: ByteReadChannel = httpResponse.body()
var result = byteArrayOf()
while (!channel.isClosedForRead) {
val packet = channel.readRemaining(4096)
while (!packet.isEmpty) {
val bytes = packet.readBytes()
result += bytes
println("Received ${result.size} bytes / ${httpResponse.contentLength()}")
}
}
result
}
}

View File

@ -0,0 +1,10 @@
package com.github.diegoberaldin.racconforlemmy.core.utils
import org.koin.core.module.Module
interface GalleryHelper {
fun saveToGallery(bytes: ByteArray, name: String)
}
expect val galleryHelperModule: Module

View File

@ -15,9 +15,14 @@ import platform.Foundation.NSLocale
import platform.Foundation.NSTimeZone
import platform.Foundation.autoupdatingCurrentLocale
import platform.Foundation.localTimeZone
import platform.Foundation.timeIntervalSince1970
actual object DateTime {
actual fun epochMillis(): Long {
return (NSDate().timeIntervalSince1970 * 1000).toLong()
}
actual fun getFormattedDate(
iso8601Timestamp: String,
format: String,
@ -48,7 +53,7 @@ actual object DateTime {
.or(NSCalendarUnitDay).or(NSCalendarUnitMonth).or(NSCalendarUnitYear),
fromDate = date,
toDate = now,
options = 0,
options = 0u,
)
return when {
delta.year >= 1 -> buildString {

View File

@ -0,0 +1,33 @@
package com.github.diegoberaldin.racconforlemmy.core.utils
import kotlinx.cinterop.allocArrayOf
import kotlinx.cinterop.memScoped
import org.koin.dsl.module
import platform.Foundation.NSData
import platform.Foundation.create
import platform.UIKit.UIImage
import platform.UIKit.UIImageWriteToSavedPhotosAlbum
typealias ImageBytes = NSData
fun ByteArray.toImageBytes(): ImageBytes = memScoped {
return NSData.create(
bytes = allocArrayOf(this@toImageBytes),
length = this@toImageBytes.size.toULong()
)
}
class DefaultGalleryHelper : GalleryHelper {
override fun saveToGallery(bytes: ByteArray, name: String) {
val image = UIImage(bytes.toImageBytes())
UIImageWriteToSavedPhotosAlbum(image, null, null, null)
}
}
actual val galleryHelperModule = module {
single<GalleryHelper> {
DefaultGalleryHelper()
}
}

View File

@ -4,7 +4,7 @@ import org.koin.dsl.module
import platform.UIKit.UIActivityViewController
import platform.UIKit.UIApplication
class DefaultShareHelper() : ShareHelper {
class DefaultShareHelper : ShareHelper {
override fun share(url: String, mimeType: String) {
val shareActivity = UIActivityViewController(listOf(url), null)
val rvc = UIApplication.sharedApplication().keyWindow?.rootViewController

View File

@ -1,5 +1,6 @@
package com.github.diegoberaldin.raccoonforlemmy.di
import com.github.diegoberaldin.racconforlemmy.core.utils.galleryHelperModule
import com.github.diegoberaldin.racconforlemmy.core.utils.hapticFeedbackModule
import com.github.diegoberaldin.racconforlemmy.core.utils.shareHelperModule
import com.github.diegoberaldin.raccoonforlemmy.core.api.di.coreApiModule
@ -28,6 +29,7 @@ val sharedHelperModule = module {
hapticFeedbackModule,
localizationModule,
shareHelperModule,
galleryHelperModule,
homeTabModule,
inboxTabModule,
profileTabModule,

View File

@ -1,6 +1,7 @@
package com.github.diegoberaldin.raccoonforlemmy.di
import com.github.diegoberaldin.racconforlemmy.core.utils.AppInfo
import com.github.diegoberaldin.racconforlemmy.core.utils.galleryHelperModule
import com.github.diegoberaldin.racconforlemmy.core.utils.hapticFeedbackModule
import com.github.diegoberaldin.racconforlemmy.core.utils.shareHelperModule
import com.github.diegoberaldin.raccoonforlemmy.core.api.di.coreApiModule
@ -31,6 +32,7 @@ fun initKoin() {
hapticFeedbackModule,
localizationModule,
shareHelperModule,
galleryHelperModule,
homeTabModule,
inboxTabModule,
profileTabModule,