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,