diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 9840d94e9..8604f5b01 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -20,4 +20,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/components/ProgressHud.kt b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/components/ProgressHud.kt new file mode 100644 index 000000000..e9a8bcf26 --- /dev/null +++ b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/components/ProgressHud.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/di/CommonUiModule.kt b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/di/CommonUiModule.kt index d95fae562..86eaa9946 100644 --- a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/di/CommonUiModule.kt +++ b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/di/CommonUiModule.kt @@ -103,6 +103,7 @@ val commonUiModule = module { ZoomableImageViewModel( mvi = DefaultMviModel(ZoomableImageMviModel.UiState()), shareHelper = get(), + galleryHelper = get(), ) } } diff --git a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/image/ZoomableImageMviModel.kt b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/image/ZoomableImageMviModel.kt index 65da1f68c..28e6f5480 100644 --- a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/image/ZoomableImageMviModel.kt +++ b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/image/ZoomableImageMviModel.kt @@ -6,6 +6,7 @@ interface ZoomableImageMviModel : MviModel { sealed interface Intent { data class Share(val url: String) : Intent + data class SaveToGallery(val url: String) : Intent } data class UiState(val loading: Boolean = false) diff --git a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/image/ZoomableImageScreen.kt b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/image/ZoomableImageScreen.kt index a11b411ef..703c9722d 100644 --- a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/image/ZoomableImageScreen.kt +++ b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/image/ZoomableImageScreen.kt @@ -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() + } } } \ No newline at end of file diff --git a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/image/ZoomableImageViewModel.kt b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/image/ZoomableImageViewModel.kt index 5c3258b17..85ab4f3bc 100644 --- a/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/image/ZoomableImageViewModel.kt +++ b/core-commonui/src/commonMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/commonui/image/ZoomableImageViewModel.kt @@ -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, private val shareHelper: ShareHelper, + private val galleryHelper: GalleryHelper, ) : ScreenModel, MviModel 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) } + } } } } \ No newline at end of file diff --git a/core-utils/build.gradle.kts b/core-utils/build.gradle.kts index 6d644f5a2..a7881cdc2 100644 --- a/core-utils/build.gradle.kts +++ b/core-utils/build.gradle.kts @@ -40,6 +40,7 @@ kotlin { implementation(compose.components.resources) implementation(libs.koin.core) + implementation(libs.ktor.cio) implementation(projects.resources) } } diff --git a/core-utils/src/androidMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/DateTime.kt b/core-utils/src/androidMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/DateTime.kt index de2a57790..c1003b5f2 100644 --- a/core-utils/src/androidMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/DateTime.kt +++ b/core-utils/src/androidMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/DateTime.kt @@ -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, diff --git a/core-utils/src/androidMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/GalleryHelper.kt b/core-utils/src/androidMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/GalleryHelper.kt new file mode 100644 index 000000000..dd46c0e07 --- /dev/null +++ b/core-utils/src/androidMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/GalleryHelper.kt @@ -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 { + DefaultGalleryHelper( + context = get() + ) + } +} diff --git a/core-utils/src/commonMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/DateTime.kt b/core-utils/src/commonMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/DateTime.kt index 12e80497b..65ec67314 100644 --- a/core-utils/src/commonMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/DateTime.kt +++ b/core-utils/src/commonMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/DateTime.kt @@ -1,6 +1,8 @@ package com.github.diegoberaldin.racconforlemmy.core.utils expect object DateTime { + fun epochMillis(): Long + fun getFormattedDate( iso8601Timestamp: String, format: String, diff --git a/core-utils/src/commonMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/Extensions.kt b/core-utils/src/commonMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/Extensions.kt index e1d5342ad..109e75c1c 100644 --- a/core-utils/src/commonMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/Extensions.kt +++ b/core-utils/src/commonMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/Extensions.kt @@ -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 + } +} \ No newline at end of file diff --git a/core-utils/src/commonMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/GalleryHelper.kt b/core-utils/src/commonMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/GalleryHelper.kt new file mode 100644 index 000000000..d2b7e59e6 --- /dev/null +++ b/core-utils/src/commonMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/GalleryHelper.kt @@ -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 diff --git a/core-utils/src/iosMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/DateTime.kt b/core-utils/src/iosMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/DateTime.kt index 6dd90819c..60ddc1ac6 100644 --- a/core-utils/src/iosMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/DateTime.kt +++ b/core-utils/src/iosMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/DateTime.kt @@ -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 { diff --git a/core-utils/src/iosMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/GalleryHelper.kt b/core-utils/src/iosMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/GalleryHelper.kt new file mode 100644 index 000000000..e76381ff4 --- /dev/null +++ b/core-utils/src/iosMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/GalleryHelper.kt @@ -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 { + DefaultGalleryHelper() + } +} diff --git a/core-utils/src/iosMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/ShareHelper.kt b/core-utils/src/iosMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/ShareHelper.kt index 8902cc9bc..f27fb9ed3 100644 --- a/core-utils/src/iosMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/ShareHelper.kt +++ b/core-utils/src/iosMain/kotlin/com/github/diegoberaldin/racconforlemmy/core/utils/ShareHelper.kt @@ -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 diff --git a/shared/src/androidMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/di/DiHelper.kt b/shared/src/androidMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/di/DiHelper.kt index e82f6b413..909b41856 100644 --- a/shared/src/androidMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/di/DiHelper.kt +++ b/shared/src/androidMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/di/DiHelper.kt @@ -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, diff --git a/shared/src/iosMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/di/DiHelper.kt b/shared/src/iosMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/di/DiHelper.kt index a5bb3383f..149bc3036 100644 --- a/shared/src/iosMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/di/DiHelper.kt +++ b/shared/src/iosMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/di/DiHelper.kt @@ -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,