diff --git a/core/src/main/java/io/snabble/sdk/Project.kt b/core/src/main/java/io/snabble/sdk/Project.kt index 0c53437d1..bdfa35d6b 100644 --- a/core/src/main/java/io/snabble/sdk/Project.kt +++ b/core/src/main/java/io/snabble/sdk/Project.kt @@ -5,6 +5,8 @@ import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonSyntaxException import com.google.gson.reflect.TypeToken +import io.snabble.sdk.assetservice.AssetService +import io.snabble.sdk.assetservice.assetServiceFactory import io.snabble.sdk.auth.SnabbleAuthorizationInterceptor import io.snabble.sdk.checkout.Checkout import io.snabble.sdk.codes.templates.CodeTemplate @@ -357,6 +359,9 @@ class Project internal constructor( lateinit var assets: Assets private set + lateinit var assetService: AssetService + private set + var appTheme: AppTheme? = null private set @@ -567,6 +572,8 @@ class Project internal constructor( assets = Assets(this) + assetService = assetServiceFactory(project = this, context = Snabble.application) + googlePayHelper = paymentMethodDescriptors .mapNotNull { it.paymentMethod } .firstOrNull { it == PaymentMethod.GOOGLE_PAY } @@ -579,7 +586,6 @@ class Project internal constructor( coupons.setProjectCoupons(couponList) } coupons.update() - notifyUpdate() } diff --git a/core/src/main/java/io/snabble/sdk/assetservice/AssetService.kt b/core/src/main/java/io/snabble/sdk/assetservice/AssetService.kt new file mode 100644 index 000000000..688d59dd6 --- /dev/null +++ b/core/src/main/java/io/snabble/sdk/assetservice/AssetService.kt @@ -0,0 +1,146 @@ +@file:Suppress("TooGenericExceptionCaught") + +package io.snabble.sdk.assetservice + +import android.content.Context +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.util.DisplayMetrics +import com.caverock.androidsvg.SVG +import io.snabble.sdk.Project +import io.snabble.sdk.assetservice.assets.data.AssetsRepositoryImpl +import io.snabble.sdk.assetservice.assets.data.source.LocalAssetDataSourceImpl +import io.snabble.sdk.assetservice.assets.data.source.RemoteAssetsSourceImpl +import io.snabble.sdk.assetservice.assets.domain.AssetsRepository +import io.snabble.sdk.assetservice.image.data.ImageRepositoryImpl +import io.snabble.sdk.assetservice.image.data.local.image.LocalDiskDataSourceImpl +import io.snabble.sdk.assetservice.image.data.local.image.LocalMemorySourceImpl +import io.snabble.sdk.assetservice.image.domain.ImageRepository +import io.snabble.sdk.assetservice.image.domain.model.Type +import io.snabble.sdk.assetservice.image.domain.model.UiMode +import io.snabble.sdk.utils.Logger +import java.io.InputStream +import kotlin.math.roundToInt + +interface AssetService { + + suspend fun updateAllAssets() + + suspend fun loadAsset(name: String, type: Type, uiMode: UiMode): Bitmap? +} + +internal class AssetServiceImpl( + private val displayMetrics: DisplayMetrics, + private val assetRepository: AssetsRepository, + private val imageRepository: ImageRepository, +) : AssetService { + + /** + * Updates all assets and safes them locally + */ + override suspend fun updateAllAssets() { + assetRepository.updateAllAssets() + } + + /** + * Loads an asset and returns it converted as [Bitmap]. + * Bitmap type can be any of these [Type]. + * To define the [UiMode] use the helper function [Context.getUiMode] or set it directly if needed. + */ + override suspend fun loadAsset(name: String, type: Type, uiMode: UiMode): Bitmap? { + val bitmap = when (val bitmap = imageRepository.getBitmap(key = name)) { + null -> createBitmap(name, type, uiMode) + else -> bitmap + } + + if (bitmap == null) { + val newBitmap = updateAssetsAndRetry(name, type, uiMode) + return newBitmap?.also { + imageRepository.putBitmap(name, it) + } + } else { + //Save converted bitmap + imageRepository.putBitmap(name, bitmap) + } + + return bitmap + } + + private fun createSVGBitmap(data: InputStream): Bitmap? { + val svg = SVG.getFromInputStream(data) + return try { + + val width = svg.getDocumentWidth() * displayMetrics.density + val height = svg.getDocumentHeight() * displayMetrics.density + + // Set the SVG's view box to the desired size + svg.setDocumentWidth(width) + svg.setDocumentHeight(height) + + // Create bitmap and canvas + val bitmap = androidx.core.graphics.createBitmap(width.roundToInt(), height.roundToInt()) + val canvas = Canvas(bitmap) + + // Render SVG to canvas + svg.renderToCanvas(canvas) + + bitmap + } catch (e: Exception) { + Logger.e("Error converting SVG to bitmap", e) + null + } + } + + private suspend fun createBitmap(name: String, type: Type, uiMode: UiMode): Bitmap? { + val cachedAsset = + assetRepository.loadAsset(name = name, type = type, uiMode = uiMode) ?: return null + return when (type) { + Type.SVG -> createSVGBitmap(cachedAsset.data) + Type.JPG, + Type.WEBP -> BitmapFactory.decodeStream(cachedAsset.data) + } + } + + private suspend fun updateAssetsAndRetry(name: String, type: Type, uiMode: UiMode): Bitmap? { + assetRepository.updateAllAssets() + return createBitmap(name, type, uiMode) + } +} + +fun assetServiceFactory( + project: Project, + context: Context +): AssetService { + val localDiskDataSource = LocalDiskDataSourceImpl(storageDirectory = project.internalStorageDirectory) + val localMemoryDataSource = LocalMemorySourceImpl() + val imageRepository = ImageRepositoryImpl( + localMemoryDataSource = localMemoryDataSource, + localDiskDataSource = localDiskDataSource + ) + + val localAssetDataSource = LocalAssetDataSourceImpl(project) + val remoteAssetsSource = RemoteAssetsSourceImpl(project) + val assetRepository = AssetsRepositoryImpl( + remoteAssetsSource = remoteAssetsSource, + localAssetDataSource = localAssetDataSource + ) + + return AssetServiceImpl( + assetRepository = assetRepository, + imageRepository = imageRepository, + displayMetrics = context.resources.displayMetrics + ) +} + +fun Context.getUiMode() = if (isDarkMode()) UiMode.NIGHT else UiMode.DAY + +// Method 2: Extension function for cleaner usage +private fun Context.isDarkMode(): Boolean { + return when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_YES -> true + Configuration.UI_MODE_NIGHT_NO -> false + else -> false + } +} diff --git a/core/src/main/java/io/snabble/sdk/assetservice/assets/data/AssetsRepositoryImpl.kt b/core/src/main/java/io/snabble/sdk/assetservice/assets/data/AssetsRepositoryImpl.kt new file mode 100644 index 000000000..530017c8e --- /dev/null +++ b/core/src/main/java/io/snabble/sdk/assetservice/assets/data/AssetsRepositoryImpl.kt @@ -0,0 +1,60 @@ +package io.snabble.sdk.assetservice.assets.data + +import io.snabble.sdk.assetservice.assets.data.source.LocalAssetDataSource +import io.snabble.sdk.assetservice.assets.data.source.RemoteAssetsSource +import io.snabble.sdk.assetservice.assets.data.source.dto.AssetDto +import io.snabble.sdk.assetservice.assets.data.source.dto.ManifestDto +import io.snabble.sdk.assetservice.assets.domain.AssetsRepository +import io.snabble.sdk.assetservice.assets.domain.model.Asset +import io.snabble.sdk.assetservice.image.domain.model.Type +import io.snabble.sdk.assetservice.image.domain.model.UiMode +import io.snabble.sdk.utils.Logger +import org.apache.commons.io.FilenameUtils + +internal class AssetsRepositoryImpl( + private val remoteAssetsSource: RemoteAssetsSource, + private val localAssetDataSource: LocalAssetDataSource +) : AssetsRepository { + + override suspend fun updateAllAssets() { + Logger.d("Start updating all assets. Loading manifest...") + val manifest: ManifestDto = loadManifest() ?: return + + removeDeletedAssets(manifest) + + Logger.d("Clean up orphaned files...") + localAssetDataSource.cleanupOrphanedFiles() + + val newAssets = manifest.files.filterNot { localAssetDataSource.assetExists(it.name) } + Logger.d("Filtered new assets $newAssets") + + Logger.d("Continue with loading all new assets...") + val assets: List = remoteAssetsSource.downloadAllAssets(newAssets) + + Logger.d("Saving new assets $assets locally...") + localAssetDataSource.saveMultipleAssets(assets = assets) + } + + private suspend fun removeDeletedAssets(manifest: ManifestDto) { + val remoteAssetNames: Set = manifest.files.map { it.name }.toSet() + val deadAssets: List = localAssetDataSource.listAssets().filterNot { it in remoteAssetNames } + Logger.d("Removing deleted assets $deadAssets...") + localAssetDataSource.deleteAsset(deadAssets) + } + + private suspend fun loadManifest(): ManifestDto? = remoteAssetsSource.downloadManifest().also { + if (it == null) Logger.e("Manifest couldn't be loaded") + } + + override suspend fun loadAsset(name: String, type: Type, uiMode: UiMode): Asset? = + getLocalAsset(filename = name.createFileName(type, uiMode))?.toModel() + + private suspend fun getLocalAsset(filename: String): AssetDto? = localAssetDataSource.loadAsset(filename) + + private fun String.createFileName(type: Type, uiMode: UiMode): String { + val cleanedName = FilenameUtils.removeExtension(this) + return "$cleanedName${uiMode.value}${type.value}" + } +} + +private fun AssetDto.toModel() = Asset(name = name, hash = hash, data = data) diff --git a/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/LocalAssetDataSource.kt b/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/LocalAssetDataSource.kt new file mode 100644 index 000000000..4f97cfef9 --- /dev/null +++ b/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/LocalAssetDataSource.kt @@ -0,0 +1,176 @@ +@file:Suppress("TooGenericExceptionCaught") + +package io.snabble.sdk.assetservice.assets.data.source + +import com.google.gson.annotations.SerializedName +import io.snabble.sdk.Project +import io.snabble.sdk.assetservice.assets.data.source.dto.AssetDto +import io.snabble.sdk.utils.GsonHolder +import io.snabble.sdk.utils.Logger +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream + +internal interface LocalAssetDataSource { + + suspend fun loadAsset(name: String): AssetDto? + suspend fun saveMultipleAssets(assets: List) + fun assetExists(name: String): Boolean + fun listAssets(): List + suspend fun deleteAsset(names: List) + suspend fun cleanupOrphanedFiles() +} + +internal class LocalAssetDataSourceImpl( + private val project: Project, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : LocalAssetDataSource { + + private val assetsDir = File(project.internalStorageDirectory, "assets/") + private val manifestFile = File(project.internalStorageDirectory, "assets_v2.json") + + private var manifest = ManifestFile() + + init { + assetsDir.mkdirs() + loadManifest() + } + + override suspend fun loadAsset(name: String): AssetDto? = withContext(dispatcher) { + try { + Logger.d("Loading asset $name...") + + return@withContext manifest.assets[name] + ?.takeIf { File(it.filePath).exists() } + ?.let { file -> + + Logger.d("Asset loaded for $name") + + AssetDto( + name = name, + data = File(file.filePath).inputStream(), + hash = file.hash + ) + }.also { + if (it == null) { + Logger.e("Asset $name not found in manifest or file does not exist") + } + } + } catch (e: Exception) { + Logger.e("Loading asset failed: ${e.message}") + return@withContext null + } + } + + override suspend fun saveMultipleAssets(assets: List) = withContext(dispatcher) { + try { + assets.forEach { assetDto -> + Logger.e("Saving asset ${assetDto.name}...") + val fileName = "${assetDto.hash}_${assetDto.name}" + val file = File(assetsDir, fileName) + file.createNewFile() + + Logger.d("Created file for ${assetDto.name}") + + + assetDto.data.use { inputStream -> + FileOutputStream(file).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + Logger.d("Saved asset content into a file ${file.absolutePath}") + + val assetFile = AssetFile( + filePath = file.absolutePath, + hash = assetDto.hash + ) + + withContext(Dispatchers.Main) { + manifest.assets[assetDto.name] = assetFile + } + } + + if (assets.isNotEmpty()) saveManifest() + } catch (e: Exception) { + Logger.e("Saving Assets failed: ${e.message}") + } + } + + private suspend fun saveManifest() = withContext(dispatcher) { + try { + val jsonString = GsonHolder.get().toJson(manifest) + manifestFile.writeText(jsonString) + Logger.d("Saved manifest for project ${project.id}") + } catch (e: Exception) { + Logger.d("Could not save manifest: ${e.message}") + throw e + } + } + + private fun loadManifest() { + try { + if (manifestFile.exists()) { + val jsonString = manifestFile.readText() + manifest = GsonHolder.get().fromJson(jsonString, ManifestFile::class.java) + Logger.d("Loaded manifest for project ${project.id} with ${manifest.assets.size} assets") + } + Logger.e("Manifest does not exist, creating new one") + manifest = ManifestFile() + } catch (e: Exception) { + Logger.e("Could not load manifest, creating new one: ${e.message}") + manifest = ManifestFile() + } + } + + override fun listAssets(): List = manifest.assets.keys.toList() + + override fun assetExists(name: String): Boolean = manifest.assets.contains(name) + + override suspend fun deleteAsset(names: List) = withContext(dispatcher) { + try { + Logger.e("Start deleting dead assets...") + names.forEach { name -> + val asset = manifest.assets[name] ?: return@forEach + + val file = File(asset.filePath) + val deleted = if (file.exists()) file.delete() else true + + if (deleted) { + manifest.assets.remove(name) + saveManifest() + } + } + Logger.e("Deleted dead assets") + } catch (e: Exception) { + Logger.e("Deletion of dead assets failed: ${e.message}") + } + } + + override suspend fun cleanupOrphanedFiles() = withContext(dispatcher) { + try { + val files = assetsDir.listFiles() ?: return@withContext + val manifestFilePaths = manifest.assets.values.map { it.filePath }.toSet() + + files.filterNotNull() + .filter { file -> file.isFile && !manifestFilePaths.contains(file.absolutePath) } + .map { file -> + Logger.d("Deleting orphaned file: ${file.name}") + file.delete() + } + } catch (e: Exception) { + Logger.e("Failed to delete orphaned files: ${e.message}") + } + } +} + +private data class AssetFile( + @SerializedName("filePath") val filePath: String, + @SerializedName("hash") val hash: String +) + +private data class ManifestFile( + @SerializedName("assets") val assets: MutableMap = mutableMapOf() +) diff --git a/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/RemoteAssetsSource.kt b/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/RemoteAssetsSource.kt new file mode 100644 index 000000000..999e60a58 --- /dev/null +++ b/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/RemoteAssetsSource.kt @@ -0,0 +1,182 @@ +package io.snabble.sdk.assetservice.assets.data.source + +import com.google.gson.JsonSyntaxException +import io.snabble.sdk.Project +import io.snabble.sdk.Snabble +import io.snabble.sdk.assetservice.assets.data.source.dto.AssetDto +import io.snabble.sdk.assetservice.assets.data.source.dto.AssetVariantDto +import io.snabble.sdk.assetservice.assets.data.source.dto.ManifestDto +import io.snabble.sdk.assetservice.assets.data.source.dto.VariantDto +import io.snabble.sdk.utils.GsonHolder +import io.snabble.sdk.utils.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import okhttp3.CacheControl +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Request +import okhttp3.Response +import okio.IOException +import org.apache.commons.io.FilenameUtils +import java.security.MessageDigest +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.time.Duration.Companion.seconds + +internal interface RemoteAssetsSource { + + /** + * Downloads the manifest containing metadata info for the Assets (e.g. name of the assets and the variant) + */ + suspend fun downloadManifest(): ManifestDto? + + /** + * Downloads the assets (e.g the bytes) for each asset variant provided. + * The asset needs to be a valid format (.svg, .jpg, .webp) and not has to be in the root folder + */ + suspend fun downloadAllAssets(files: List): List +} + +internal class RemoteAssetsSourceImpl( + private val project: Project +) : RemoteAssetsSource { + + override suspend fun downloadManifest(): ManifestDto? = + with(Dispatchers.IO) { + suspendCancellableCoroutine { continuation: Continuation -> + val assetsUrl = project.assetsUrl ?: return@suspendCancellableCoroutine + Logger.d("Loading to Manifest from: $assetsUrl") + + val request = Request.Builder() + .cacheControl(CacheControl.Builder().maxAge(30.seconds).build()) + .url(assetsUrl) + .get() + .build() + + + project.okHttpClient.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Logger.e(e.message) + continuation.resume(null) + } + + override fun onResponse(call: Call, response: Response) { + try { + if (!response.isSuccessful) { + Logger.d("Loading manifest failed: ${response.code} ${response.body.string()}") + continuation.resume(null) + return + } + + val manifestDto = GsonHolder.get().fromJson(response.body.string(), ManifestDto::class.java) + Logger.d("Loading manifest succeeded: $manifestDto") + continuation.resume(manifestDto) + } catch (e: JsonSyntaxException) { + Logger.e("Manifest parsing failed: ${e.message}") + continuation.resume(null) + } finally { + response.close() + } + } + } + ) + } + } + + override suspend fun downloadAllAssets( + files: List + ) = withContext(Dispatchers.IO) { + + val assetsUrls = files.mapNotNull { asset -> + val url: String = asset.variants[VariantDto.MDPI] ?: return@mapNotNull null + val assetsName: String = asset.name + val format = FilenameUtils.getExtension(assetsName) + when { + format.isValidFormat() && asset.name.isRootAsset() -> assetsName to url + else -> null + } + } + + Logger.d("Filtered valid assets: $assetsUrls") + + val semaphore = Semaphore(MAX_CONCURRENT_REQUESTS) + + assetsUrls + .map { (assetName, url) -> + async { + semaphore.withPermit { + loadAsset(project, url, assetName) + } + } + }.awaitAll() + .filterNotNull() + } + + private suspend fun loadAsset(project: Project, url: String, assetName: String): AssetDto? = + withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> + Logger.d("Loading asset for $url") + val request = Request.Builder() + .url(Snabble.absoluteUrl(url)) + .get() + .build() + + val call = project.okHttpClient.newCall(request) + + // Handle cancellation + continuation.invokeOnCancellation { + call.cancel() + } + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Logger.e("Loading asset failed: ${e.message}") + continuation.resume(null) + } + + override fun onResponse(call: Call, response: Response) { + if (!response.isSuccessful) { + Logger.e("Loading asset failed: ${response.code} ${response.body.string()}") + continuation.resume(null) + return + } + val bytes = response.body.bytes() + val asset = + AssetDto(data = bytes.inputStream(), hash = url.calculateHash(), name = assetName) + Logger.d("Loading assets succeeded: $asset") + continuation.resume(asset) + response.close() + } + }) + } + } + + private fun String.isValidFormat() = VALID_FORMATS.contains(this) + + private fun String.isRootAsset() = !contains("/") + + /** + * Calculate SHA-256 hash for a String + */ + fun String.calculateHash(): String { + return try { + val digest = MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(this.toByteArray(Charsets.UTF_8)) + hashBytes.joinToString("") { "%02x".format(it) } + } catch (e: Exception) { + Logger.e("Failed to calculate hash for string: ${e.message}", e) + throw e + } + } + + companion object { + + private val VALID_FORMATS = listOf("svg", "jpg", "webp") + private const val MAX_CONCURRENT_REQUESTS = 10 + } +} diff --git a/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/dto/AssetDto.kt b/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/dto/AssetDto.kt new file mode 100644 index 000000000..1596c86c3 --- /dev/null +++ b/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/dto/AssetDto.kt @@ -0,0 +1,9 @@ +package io.snabble.sdk.assetservice.assets.data.source.dto + +import java.io.InputStream + +internal data class AssetDto( + val name: String, + val hash: String, + val data: InputStream, +) diff --git a/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/dto/AssetVariantDto.kt b/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/dto/AssetVariantDto.kt new file mode 100644 index 000000000..8c12d3a99 --- /dev/null +++ b/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/dto/AssetVariantDto.kt @@ -0,0 +1,8 @@ +package io.snabble.sdk.assetservice.assets.data.source.dto + +import com.google.gson.annotations.SerializedName + +internal data class AssetVariantDto( + @SerializedName("name") var name: String, + @SerializedName("variants") var variants: MutableMap = mutableMapOf() +) diff --git a/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/dto/ManifestDto.kt b/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/dto/ManifestDto.kt new file mode 100644 index 000000000..2110705ce --- /dev/null +++ b/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/dto/ManifestDto.kt @@ -0,0 +1,7 @@ +package io.snabble.sdk.assetservice.assets.data.source.dto + +import com.google.gson.annotations.SerializedName + +internal data class ManifestDto( + @SerializedName("files") val files: List +) diff --git a/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/dto/VariantDto.kt b/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/dto/VariantDto.kt new file mode 100644 index 000000000..77d106f81 --- /dev/null +++ b/core/src/main/java/io/snabble/sdk/assetservice/assets/data/source/dto/VariantDto.kt @@ -0,0 +1,20 @@ +package io.snabble.sdk.assetservice.assets.data.source.dto + +import com.google.gson.annotations.SerializedName + +internal enum class VariantDto(var factor: String?, var density: Float) { + @SerializedName("1x") + MDPI(factor = "1x", density = 1.0f), + + @SerializedName("1.5x") + HDPI(factor = "1.5x", density = 1.5f), + + @SerializedName("2x") + XHDPI(factor = "2x", density = 2.0f), + + @SerializedName("3x") + XXHDPI(factor = "3x", density = 3.0f), + + @SerializedName("4x") + XXXHDPI(factor = "4x", density = 4.0f); +} diff --git a/core/src/main/java/io/snabble/sdk/assetservice/assets/domain/AssetsRepository.kt b/core/src/main/java/io/snabble/sdk/assetservice/assets/domain/AssetsRepository.kt new file mode 100644 index 000000000..fc0564d22 --- /dev/null +++ b/core/src/main/java/io/snabble/sdk/assetservice/assets/domain/AssetsRepository.kt @@ -0,0 +1,12 @@ +package io.snabble.sdk.assetservice.assets.domain + +import io.snabble.sdk.assetservice.assets.domain.model.Asset +import io.snabble.sdk.assetservice.image.domain.model.Type +import io.snabble.sdk.assetservice.image.domain.model.UiMode + +internal interface AssetsRepository { + + suspend fun updateAllAssets() + + suspend fun loadAsset(name: String, type: Type, uiMode: UiMode): Asset? +} diff --git a/core/src/main/java/io/snabble/sdk/assetservice/assets/domain/model/Asset.kt b/core/src/main/java/io/snabble/sdk/assetservice/assets/domain/model/Asset.kt new file mode 100644 index 000000000..a18f04bd1 --- /dev/null +++ b/core/src/main/java/io/snabble/sdk/assetservice/assets/domain/model/Asset.kt @@ -0,0 +1,9 @@ +package io.snabble.sdk.assetservice.assets.domain.model + +import java.io.InputStream + +internal data class Asset( + val name: String, + val hash: String, + val data: InputStream, +) diff --git a/core/src/main/java/io/snabble/sdk/assetservice/image/data/ImageRepositoryImpl.kt b/core/src/main/java/io/snabble/sdk/assetservice/image/data/ImageRepositoryImpl.kt new file mode 100644 index 000000000..0e15625e0 --- /dev/null +++ b/core/src/main/java/io/snabble/sdk/assetservice/image/data/ImageRepositoryImpl.kt @@ -0,0 +1,81 @@ +package io.snabble.sdk.assetservice.image.data + +import android.graphics.Bitmap +import io.snabble.sdk.assetservice.image.data.local.image.LocalDiskDataSource +import io.snabble.sdk.assetservice.image.data.local.image.LocalMemoryDataSource +import io.snabble.sdk.assetservice.image.domain.ImageRepository +import io.snabble.sdk.utils.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +internal class ImageRepositoryImpl( + private val localMemoryDataSource: LocalMemoryDataSource, + private val localDiskDataSource: LocalDiskDataSource +) : ImageRepository { + + private val cacheScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + init { + cacheScope.launch { + localMemoryDataSource.evictedItems.filterNotNull().collect { (key, bitmap) -> + localDiskDataSource.saveToDisk(key, bitmap) + } + } + } + + override suspend fun getBitmap(key: String): Bitmap? = withContext(Dispatchers.IO) { + + // 1. Check memory cache first (fastest ~0.1ms) + localMemoryDataSource.getBitmap(key)?.let { + return@withContext it + } + + // 2. Check disk cache (medium speed ~5-20ms) + localDiskDataSource.getBitmap(key)?.let { + // Save it in memory cache for faster access next time + localMemoryDataSource.putBitmap(key, it) + return@withContext it + } + + Logger.d("Image cache missing") + return@withContext null + } + + /** + * Manually put a bitmap in cache + */ + override suspend fun putBitmap(key: String, bitmap: Bitmap) { + localMemoryDataSource.putBitmap(key, bitmap) + localDiskDataSource.saveToDisk(key, bitmap) + } + + /** + * Clear memory cache (disk cache remains) + */ + fun clearMemoryCache() { + localMemoryDataSource.clearCache() + Logger.d("Memory cache cleared") + } + + /** + * Clear everything + */ + suspend fun clearAllCaches() = withContext(Dispatchers.IO) { + clearMemoryCache() + localDiskDataSource.clearCache() + Logger.d("All caches cleared") + } + + /** + * Clean up resources - call this in onDestroy + */ + fun close() { + cacheScope.cancel() + Logger.d("Cache manager closed") + } +} diff --git a/core/src/main/java/io/snabble/sdk/assetservice/image/data/local/image/LocalDiskDataSource.kt b/core/src/main/java/io/snabble/sdk/assetservice/image/data/local/image/LocalDiskDataSource.kt new file mode 100644 index 000000000..b03a54c6a --- /dev/null +++ b/core/src/main/java/io/snabble/sdk/assetservice/image/data/local/image/LocalDiskDataSource.kt @@ -0,0 +1,154 @@ +@file:Suppress("TooGenericExceptionCaught") + +package io.snabble.sdk.assetservice.image.data.local.image + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import io.snabble.sdk.utils.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +internal interface LocalDiskDataSource { + + suspend fun getBitmap(key: String): Bitmap? + suspend fun saveToDisk(key: String, bitmap: Bitmap): Any + suspend fun clearCache() +} + +internal class LocalDiskDataSourceImpl( + private val storageDirectory: File, +) : LocalDiskDataSource { + + private val diskCacheDir: File + private val cacheScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + init { + diskCacheDir = initDiskCacheDir() + } + + private fun initDiskCacheDir(): File { + val cacheDir = File(storageDirectory, DISK_CACHE_SUBDIR) + if (!cacheDir.exists()) { + cacheDir.mkdirs() + } + Logger.d("Init disk cache dir: ${cacheDir.absolutePath}") + + // Clean up old cache files if over size limit + cacheScope.launch { + cleanupDiskCache() + } + + return cacheDir + } + + override suspend fun getBitmap(key: String): Bitmap? = withContext(Dispatchers.IO) { + val cacheFile = getCacheFile(key) + + return@withContext if (cacheFile.exists()) { + try { + FileInputStream(cacheFile).use { inputStream -> + val bitmap = BitmapFactory.decodeStream(inputStream) + // Update file access time for LRU cleanup + cacheFile.setLastModified(System.currentTimeMillis()) + bitmap + } + } catch (e: IOException) { + Logger.e("Error reading image from disk cache: $key", e) + // Delete corrupted file + cacheFile.delete() + null + } + } else { + null + } + } + + private fun getCacheFile(key: String): File { + val safeFileName = key.toMD5() + CACHE_FILE_EXTENSION + return File(diskCacheDir, safeFileName) + } + + override suspend fun saveToDisk(key: String, bitmap: Bitmap) = withContext(Dispatchers.IO) { + val cacheFile = getCacheFile(key) + + try { + FileOutputStream(cacheFile).use { outputStream -> + if (bitmap.compress(Bitmap.CompressFormat.PNG, PNG_COMPRESSION_QUALITY, outputStream)) { + Logger.d("Saved image to disk: $key") + } else { + cacheFile.delete() + } + } + } catch (e: IOException) { + Logger.e("Error writing image to disk cache: $key", e) + cacheFile.delete() + } + } + + override suspend fun clearCache() { + try { + diskCacheDir.listFiles()?.forEach { it.delete() } + } catch (e: IOException) { + Logger.e("Error clearing disk cache", e) + } + } + + private suspend fun cleanupDiskCache() = withContext(Dispatchers.IO) { + try { + val files = diskCacheDir.listFiles() ?: return@withContext + val totalSize = files.sumOf { it.length() } + + if (totalSize > MAX_DISK_CACHE_SIZE) { + Logger.d("Cleaning disk cache: ${totalSize / (BYTES_TO_MB)}MB") + + // Sort by last modified (oldest first) + val sortedFiles = files.sortedBy { it.lastModified() } + var currentSize = totalSize + + for (file in sortedFiles) { + if (currentSize <= MAX_DISK_CACHE_SIZE * CACHE_CLEANUP_TARGET_RATIO) break // Keep 80% of max size + + currentSize -= file.length() + file.delete() + Logger.d("Deleted old cache file: ${file.name}") + } + } + } catch (e: Exception) { + Logger.e("Error during cache cleanup", e) + } + } + + private fun String.toMD5(): String { + return try { + val digest = MessageDigest.getInstance("MD5") + digest.update(toByteArray()) + digest.digest().joinToString("") { "%02x".format(it) } + } catch (e: NoSuchAlgorithmException) { + Logger.e("MD5 algorithm not available", e) + hashCode().toString() + } catch (e: OutOfMemoryError) { + Logger.e("Out of memory creating MD5", e) + hashCode().toString() + } + } + + companion object { + + // Disk cache configuration + private const val DISK_CACHE_SUBDIR = "assets/" + private const val MAX_DISK_CACHE_SIZE = 50 * 1024 * 1024L // 50MB + private const val CACHE_FILE_EXTENSION = ".cache" + private const val PNG_COMPRESSION_QUALITY = 100 + private const val BYTES_TO_MB = 1024 * 1024 + private const val CACHE_CLEANUP_TARGET_RATIO = 0.8 + } +} diff --git a/core/src/main/java/io/snabble/sdk/assetservice/image/data/local/image/LocalMemoryDataSource.kt b/core/src/main/java/io/snabble/sdk/assetservice/image/data/local/image/LocalMemoryDataSource.kt new file mode 100644 index 000000000..2e03f5f9b --- /dev/null +++ b/core/src/main/java/io/snabble/sdk/assetservice/image/data/local/image/LocalMemoryDataSource.kt @@ -0,0 +1,89 @@ +package io.snabble.sdk.assetservice.image.data.local.image + +import android.graphics.Bitmap +import android.util.LruCache +import io.snabble.sdk.utils.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext + +internal interface LocalMemoryDataSource { + + val evictedItems: Flow?> + suspend fun getBitmap(key: String): Bitmap? + suspend fun putBitmap(key: String, bitmap: Bitmap) + + fun clearCache() +} + +internal class LocalMemorySourceImpl : LocalMemoryDataSource { + + private val memoryCache: LruCache + + private val _evictedItems: MutableStateFlow?> = MutableStateFlow(null) + override val evictedItems: StateFlow?> = _evictedItems.asStateFlow() + + init { + memoryCache = initMemoryCache() + } + + private fun initMemoryCache(): LruCache { + val cacheSize = calculateMemoryCacheSize() + Logger.d("Setup memory cache ${cacheSize / KB_TO_MB} MB") + + return object : LruCache(cacheSize) { + override fun sizeOf(key: String, bitmap: Bitmap): Int { + return bitmap.byteCount / BYTES_TO_KB + } + + override fun entryRemoved( + evicted: Boolean, + key: String, + oldValue: Bitmap, + newValue: Bitmap? + ) { + // When evicted from memory, save to disk asynchronously + if (evicted) { + _evictedItems.update { key to oldValue } + } + } + } + } + + private fun calculateMemoryCacheSize(): Int { + val maxMemoryKB = (Runtime.getRuntime().maxMemory() / BYTES_TO_KB).toInt() + val calculatedCacheKB = maxMemoryKB / MEMORY_CACHE_FRACTION + val minCacheKB = MIN_CACHE_SIZE_MB * KB_TO_MB + val maxCacheKB = MAX_CACHE_SIZE_MB * KB_TO_MB + return calculatedCacheKB.coerceIn(minCacheKB, maxCacheKB) + } + + override suspend fun getBitmap(key: String): Bitmap? = withContext(Dispatchers.IO) { + memoryCache.get(key)?.let { bitmap -> + Logger.d("Load image from memory: $key") + return@withContext bitmap + } + } + + override suspend fun putBitmap(key: String, bitmap: Bitmap) = withContext(Dispatchers.IO) { + memoryCache.put(key, bitmap) + Logger.d("Saving image to memory: $key") + } + + override fun clearCache() { + memoryCache.evictAll() + } + + companion object { + + private const val BYTES_TO_KB = 1024 + private const val KB_TO_MB = 1024 + private const val MEMORY_CACHE_FRACTION = 8 // Use 1/8 of available heap + private const val MIN_CACHE_SIZE_MB = 4 + private const val MAX_CACHE_SIZE_MB = 64 + } +} diff --git a/core/src/main/java/io/snabble/sdk/assetservice/image/domain/ImageRepository.kt b/core/src/main/java/io/snabble/sdk/assetservice/image/domain/ImageRepository.kt new file mode 100644 index 000000000..c06c736ea --- /dev/null +++ b/core/src/main/java/io/snabble/sdk/assetservice/image/domain/ImageRepository.kt @@ -0,0 +1,9 @@ +package io.snabble.sdk.assetservice.image.domain + +import android.graphics.Bitmap + +internal interface ImageRepository { + + suspend fun getBitmap(key: String): Bitmap? + suspend fun putBitmap(key: String, bitmap: Bitmap) +} diff --git a/core/src/main/java/io/snabble/sdk/assetservice/image/domain/model/Type.kt b/core/src/main/java/io/snabble/sdk/assetservice/image/domain/model/Type.kt new file mode 100644 index 000000000..2a0d04dc5 --- /dev/null +++ b/core/src/main/java/io/snabble/sdk/assetservice/image/domain/model/Type.kt @@ -0,0 +1,11 @@ +package io.snabble.sdk.assetservice.image.domain.model + +/** + * Enum class for describing the image type + */ +enum class Type(val value: String) { + + SVG(".svg"), + JPG(".jpg"), + WEBP(".webp") +} diff --git a/core/src/main/java/io/snabble/sdk/assetservice/image/domain/model/UiMode.kt b/core/src/main/java/io/snabble/sdk/assetservice/image/domain/model/UiMode.kt new file mode 100644 index 000000000..c8de59167 --- /dev/null +++ b/core/src/main/java/io/snabble/sdk/assetservice/image/domain/model/UiMode.kt @@ -0,0 +1,7 @@ +package io.snabble.sdk.assetservice.image.domain.model + +enum class UiMode(val value: String) { + + NIGHT("dark"), + DAY(""), +}