diff --git a/.gitignore b/.gitignore index 33afd0a1..aabb1218 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ build/ .gradle/ terraform.tfvars .terraform/ +/spotiflyer-ios/Pods/ diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 4ca61100..5afd25b0 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -75,7 +75,7 @@ android { } compileOptions { // Flag to enable support for the new language APIs - coreLibraryDesugaringEnabled = true + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } @@ -132,7 +132,7 @@ dependencies { implementation("dev.icerock.moko:parcelize:0.6.1") implementation("com.github.shabinder:storage-chooser:2.0.4.45") - implementation("com.google.accompanist:accompanist-insets:0.8.1") + implementation("com.google.accompanist:accompanist-insets:0.9.0") // Test testImplementation("junit:junit:4.13.2") diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 5015d070..58b5947d 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -32,7 +32,7 @@ repositories { } dependencies { - implementation("com.android.tools.build:gradle:4.0.2") + implementation("com.android.tools.build:gradle:4.2.0") implementation("com.google.gms:google-services:4.3.5") implementation("com.google.firebase:perf-plugin:1.3.5") implementation("com.google.firebase:firebase-crashlytics-gradle:2.5.2") diff --git a/buildSrc/buildSrc/build.gradle.kts b/buildSrc/buildSrc/build.gradle.kts index 3d7a9541..876c922b 100644 --- a/buildSrc/buildSrc/build.gradle.kts +++ b/buildSrc/buildSrc/build.gradle.kts @@ -3,5 +3,5 @@ plugins { } repositories { - jcenter() + mavenCentral() } diff --git a/buildSrc/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/buildSrc/src/main/kotlin/Versions.kt index 5acf9636..6e6a3b56 100644 --- a/buildSrc/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/buildSrc/src/main/kotlin/Versions.kt @@ -106,7 +106,7 @@ object Decompose { const val extensionsCompose = "com.arkivanov.decompose:extensions-compose-jetbrains:$VERSION" } object MVIKotlin { - private const val VERSION = "2.0.2" + private const val VERSION = "2.0.3" const val rx = "com.arkivanov.mvikotlin:rx:$VERSION" const val mvikotlin = "com.arkivanov.mvikotlin:mvikotlin:$VERSION" const val mvikotlinMain = "com.arkivanov.mvikotlin:mvikotlin-main:$VERSION" diff --git a/buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts b/buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts index d04c331e..310de475 100644 --- a/buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts +++ b/buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts @@ -78,6 +78,7 @@ kotlin { implementation(Extras.kermit) implementation("co.touchlab:stately-common:1.1.6") implementation("dev.icerock.moko:parcelize:0.6.1") + // implementation("io.github.reactivecircus.cache4k:cache4k:0.2.0-SNAPSHOT") // Local Maven implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3-native-mt") { isForce = true diff --git a/common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/AndroidImages.kt b/common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/AndroidImages.kt index 05f9dcdf..0ee8eb3a 100644 --- a/common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/AndroidImages.kt +++ b/common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/AndroidImages.kt @@ -19,6 +19,7 @@ package com.shabinder.common.uikit import androidx.compose.animation.Crossfade +import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -129,6 +130,12 @@ actual fun YoutubeMusicLogo() = painterResource(R.drawable.ic_youtube_music_logo @Composable actual fun GithubLogo() = painterResource(R.drawable.ic_github) +@Composable +actual fun PaypalLogo() = painterResource(R.drawable.ic_paypal_logo) + +@Composable +actual fun RazorPay() = painterResource(R.drawable.ic_indian_rupee) + @Composable actual fun Toast( text: String, diff --git a/common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/DonationDialog.kt b/common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/DonationDialog.kt new file mode 100644 index 00000000..4a067acd --- /dev/null +++ b/common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/DonationDialog.kt @@ -0,0 +1,141 @@ +package com.shabinder.common.uikit + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.AlertDialog +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CardGiftcard +import androidx.compose.material.icons.rounded.Share +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.shabinder.common.models.methods +import com.shabinder.common.uikit.PaypalLogo +import com.shabinder.common.uikit.RazorPay +import com.shabinder.common.uikit.SpotiFlyerShapes +import com.shabinder.common.uikit.SpotiFlyerTypography +import com.shabinder.common.uikit.colorAccent + +@OptIn(ExperimentalAnimationApi::class) +@Composable +actual fun DonationDialog( + isVisible:Boolean, + onDismiss:()->Unit +){ + AnimatedVisibility( + isVisible + ) { + + Dialog(onDismiss) { + Card( + modifier = Modifier.fillMaxWidth(), + border = BorderStroke(1.dp, Color.Gray) // Gray + ) { + Column(Modifier.padding(16.dp)) { + Text( + "Support Us", + style = SpotiFlyerTypography.h5, + textAlign = TextAlign.Center, + color = colorAccent, + modifier = Modifier + ) + Spacer(modifier = Modifier.padding(vertical = 4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable( + onClick = { + onDismiss() + methods.value.openPlatform("", "https://www.paypal.com/paypalme/shabinder") + } + ) + .padding(vertical = 6.dp) + ) { + Icon(PaypalLogo(), "Paypal Logo", tint = Color(0xFFCCCCCC)) + Spacer(modifier = Modifier.padding(start = 16.dp)) + Column { + Text( + text = "Paypal", + style = SpotiFlyerTypography.h6 + ) + Text( + text = "International Donations (Outside India).", + style = SpotiFlyerTypography.subtitle2 + ) + } + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 6.dp) + .clickable(onClick = { + onDismiss() + methods.value.giveDonation() + }), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(RazorPay(), "Indian Rupee Logo", Modifier.size(32.dp), tint = Color(0xFFCCCCCC)) + Spacer(modifier = Modifier.padding(start = 16.dp)) + Column { + Text( + text = "RazorPay", + style = SpotiFlyerTypography.h6 + ) + Text( + text = "Indian Donations (UPI / PayTM / PhonePe / Cards).", + style = SpotiFlyerTypography.subtitle2 + ) + } + } + } + } + } + + /*AlertDialog( + buttons = { + *//* TextButton({ + //Retry Network Connection + }, + Modifier.padding(bottom = 16.dp,start = 16.dp,end = 16.dp).fillMaxWidth().background(Color(0xFFFC5C7D),shape = RoundedCornerShape(size = 8.dp)).padding(horizontal = 8.dp), + ){ + Text("Retry",color = Color.Black,fontSize = 18.sp,textAlign = TextAlign.Center) + Icon(Icons.Rounded.SyncProblem,"Check Network Connection Again") + } + *//*}, + *//*title = { + Column { + Text( + "Support Us", + style = SpotiFlyerTypography.h5, + textAlign = TextAlign.Center, + color = colorAccent, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.padding(vertical = 16.dp)) + } + },*//* + backgroundColor = Color.DarkGray, + text = { + + } + ,shape = SpotiFlyerShapes.medium, + onDismissRequest = onDismiss + )*/ + } +} \ No newline at end of file diff --git a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/Dialogs.kt b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/Dialogs.kt deleted file mode 100644 index 3c87dede..00000000 --- a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/Dialogs.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * * Copyright (c) 2021 Shabinder Singh - * * This program is free software: you can redistribute it and/or modify - * * it under the terms of the GNU General Public License as published by - * * the Free Software Foundation, either version 3 of the License, or - * * (at your option) any later version. - * * - * * This program is distributed in the hope that it will be useful, - * * but WITHOUT ANY WARRANTY; without even the implied warranty of - * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * * GNU General Public License for more details. - * * - * * You should have received a copy of the GNU General Public License - * * along with this program. If not, see . - */ - -package com.shabinder.common.uikit diff --git a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/ExpectImages.kt b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/ExpectImages.kt index 237281e9..21fa52de 100644 --- a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/ExpectImages.kt +++ b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/ExpectImages.kt @@ -61,6 +61,12 @@ expect fun YoutubeMusicLogo(): Painter @Composable expect fun GithubLogo(): Painter +@Composable +expect fun PaypalLogo(): Painter + +@Composable +expect fun RazorPay(): Painter + @Composable expect fun HeartIcon(): Painter @@ -69,3 +75,9 @@ expect fun DownloadImageError() @Composable expect fun DownloadImageArrow(modifier: Modifier) + +@Composable +expect fun DonationDialog( + isVisible:Boolean, + onDismiss:()->Unit +) \ No newline at end of file diff --git a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/SpotiFlyerMainUi.kt b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/SpotiFlyerMainUi.kt index 03432144..8019ba73 100644 --- a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/SpotiFlyerMainUi.kt +++ b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/SpotiFlyerMainUi.kt @@ -59,6 +59,9 @@ import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Share import androidx.compose.runtime.Composable 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.draw.clip @@ -318,9 +321,18 @@ fun AboutColumn(modifier: Modifier = Modifier) { ) } } + + var isDonationDialogVisible by remember { mutableStateOf(false) } + + DonationDialog( + isDonationDialogVisible + ) { + isDonationDialogVisible = false + } + Row( modifier = modifier.fillMaxWidth().padding(vertical = 6.dp) - .clickable(onClick = { methods.value.giveDonation() }), + .clickable(onClick = { isDonationDialogVisible = true }), verticalAlignment = Alignment.CenterVertically ) { Icon(Icons.Rounded.CardGiftcard, "Support Developer") @@ -331,7 +343,8 @@ fun AboutColumn(modifier: Modifier = Modifier) { style = SpotiFlyerTypography.h6 ) Text( - text = "If you think I deserve to get paid for my work, you can leave me some money here.", + text = "If you think I deserve to get paid for my work, you can support me here.", + //text = "SpotiFlyer will always be, Free and Open-Source. You can however show us that you care by sending a small donation.", style = SpotiFlyerTypography.subtitle2 ) } diff --git a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/dialogs/Donation.kt b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/dialogs/Donation.kt new file mode 100644 index 00000000..d11b3008 --- /dev/null +++ b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/dialogs/Donation.kt @@ -0,0 +1,2 @@ +package com.shabinder.common.uikit.dialogs + diff --git a/common/compose/src/desktopMain/kotlin/com/shabinder/common/uikit/DesktopImages.kt b/common/compose/src/desktopMain/kotlin/com/shabinder/common/uikit/DesktopImages.kt index 22c8e5c5..d46815e6 100644 --- a/common/compose/src/desktopMain/kotlin/com/shabinder/common/uikit/DesktopImages.kt +++ b/common/compose/src/desktopMain/kotlin/com/shabinder/common/uikit/DesktopImages.kt @@ -67,6 +67,14 @@ actual fun DownloadImageTick() { ) } +@Composable +actual fun DonationDialog( + isVisible:Boolean, + onDismiss:()->Unit +){ + +} + actual fun montserratFont() = FontFamily( Font("font/montserrat_light.ttf", FontWeight.Light), Font("font/montserrat_regular.ttf", FontWeight.Normal), @@ -125,3 +133,9 @@ actual fun YoutubeMusicLogo() = rememberVectorPainter(vectorXmlResource("drawabl @Composable actual fun GithubLogo() = rememberVectorPainter(vectorXmlResource("drawable/ic_github.xml")) as Painter + +@Composable +actual fun PaypalLogo() = rememberVectorPainter(vectorXmlResource("drawable/ic_paypal_logo.xml")) as Painter + +@Composable +actual fun RazorPay() = rememberVectorPainter(vectorXmlResource("drawable/ic_indian_rupee.xml")) as Painter diff --git a/common/data-models/build.gradle.kts b/common/data-models/build.gradle.kts index ce4b4b92..16dff061 100644 --- a/common/data-models/build.gradle.kts +++ b/common/data-models/build.gradle.kts @@ -21,10 +21,29 @@ plugins { kotlin("plugin.serialization") } +val statelyVersion = "1.1.6" +val statelyIsoVersion = "1.1.6-a1" + kotlin { sourceSets { + /* + * Depend on https://github.com/ReactiveCircus/cache4k + * -As Soon as Kotlin 1.5 and Compose becomes compatible + * */ + all { + languageSettings.apply { + progressiveMode = true + enableLanguageFeature("NewInference") + useExperimentalAnnotation("kotlin.Experimental") + useExperimentalAnnotation("kotlin.time.ExperimentalTime") + } + } commonMain { - dependencies {} + dependencies { + implementation("co.touchlab:stately-concurrency:$statelyVersion") + implementation("co.touchlab:stately-isolate:$statelyIsoVersion") + implementation("co.touchlab:stately-iso-collections:$statelyIsoVersion") + } } } } diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/Cache.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/Cache.kt new file mode 100644 index 00000000..8882b7ae --- /dev/null +++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/Cache.kt @@ -0,0 +1,152 @@ +package com.shabinder.common.caching + +import kotlin.time.Duration +import kotlin.time.TimeSource + +/** + * An in-memory key-value cache with support for time-based (expiration) and size-based evictions. + */ +public interface Cache { + + /** + * Returns the value associated with [key] in this cache, or null if there is no + * cached value for [key]. + */ + public fun get(key: Key): Value? + + /** + * Returns the value associated with [key] in this cache if exists, + * otherwise gets the value by invoking [loader], associates the value with [key] in the cache, + * and returns the cached value. + * + * Any exceptions thrown by the [loader] will be propagated to the caller of this function. + */ + public suspend fun get(key: Key, loader: suspend () -> Value): Value + + /** + * Associates [value] with [key] in this cache. If the cache previously contained a + * value associated with [key], the old value is replaced by [value]. + */ + public fun put(key: Key, value: Value) + + /** + * Discards any cached value for key [key]. + */ + public fun invalidate(key: Key) + + /** + * Discards all entries in the cache. + */ + public fun invalidateAll() + + /** + * Returns a defensive copy of cache entries as [Map]. + */ + public fun asMap(): Map + + /** + * Main entry point for creating a [Cache]. + */ + public interface Builder { + + /** + * Specifies that each entry should be automatically removed from the cache once a fixed duration + * has elapsed after the entry's creation or the most recent replacement of its value. + * + * When [duration] is zero, the cache's max size will be set to 0 + * meaning no values will be cached. + */ + public fun expireAfterWrite(duration: Duration): Builder + + /** + * Specifies that each entry should be automatically removed from the cache once a fixed duration + * has elapsed after the entry's creation, the most recent replacement of its value, or its last + * access. + * + * When [duration] is zero, the cache's max size will be set to 0 + * meaning no values will be cached. + */ + public fun expireAfterAccess(duration: Duration): Builder + + /** + * Specifies the maximum number of entries the cache may contain. + * Cache eviction policy is based on LRU - i.e. least recently accessed entries get evicted first. + * + * When [size] is 0, entries will be discarded immediately and no values will be cached. + * + * If not set, cache size will be unlimited. + */ + public fun maximumCacheSize(size: Long): Builder + + /** + * Specifies a [FakeTimeSource] for programmatically advancing the reading of the underlying + * [TimeSource] used for expiry checks in tests. + * + * If not specified, [TimeSource.Monotonic] will be used for expiry checks. + */ + public fun fakeTimeSource(fakeTimeSource: FakeTimeSource): Builder + + /** + * Builds a new instance of [Cache] with the specified configurations. + */ + public fun build(): Cache + + public companion object { + + /** + * Returns a new [Cache.Builder] instance. + */ + public fun newBuilder(): Builder = CacheBuilderImpl() + } + } +} + +/** + * A default implementation of [Cache.Builder]. + */ +internal class CacheBuilderImpl : Cache.Builder { + + private var expireAfterWriteDuration = Duration.INFINITE + + private var expireAfterAccessDuration = Duration.INFINITE + private var maxSize = UNSET_LONG + private var fakeTimeSource: FakeTimeSource? = null + + override fun expireAfterWrite(duration: Duration): CacheBuilderImpl = apply { + require(duration.isPositive()) { + "expireAfterWrite duration must be positive" + } + this.expireAfterWriteDuration = duration + } + + override fun expireAfterAccess(duration: Duration): CacheBuilderImpl = apply { + require(duration.isPositive()) { + "expireAfterAccess duration must be positive" + } + this.expireAfterAccessDuration = duration + } + + override fun maximumCacheSize(size: Long): CacheBuilderImpl = apply { + require(size >= 0) { + "maximum size must not be negative" + } + this.maxSize = size + } + + override fun fakeTimeSource(fakeTimeSource: FakeTimeSource): CacheBuilderImpl = apply { + this.fakeTimeSource = fakeTimeSource + } + + override fun build(): Cache { + return RealCache( + expireAfterWriteDuration, + expireAfterAccessDuration, + maxSize, + fakeTimeSource ?: TimeSource.Monotonic, + ) + } + + companion object { + internal const val UNSET_LONG: Long = -1 + } +} diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/FakeTimeSource.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/FakeTimeSource.kt new file mode 100644 index 00000000..58b9e5c2 --- /dev/null +++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/FakeTimeSource.kt @@ -0,0 +1,51 @@ +package com.shabinder.common.caching + +import co.touchlab.stately.concurrency.AtomicLong +import kotlin.time.AbstractLongTimeSource +import kotlin.time.Duration +import kotlin.time.DurationUnit + +/** + * A time source that has programmatically updatable readings with support for multi-threaded access in Kotlin/Native. + * + * Implementation is identical to [kotlin.time.TestTimeSource] except the internal [reading] is an [AtomicLong]. + */ +public class FakeTimeSource : AbstractLongTimeSource(unit = DurationUnit.NANOSECONDS) { + + private val reading = AtomicLong(0) + + override fun read(): Long = reading.get() + + /** + * Advances the current reading value of this time source by the specified [duration]. + * + * [duration] value is rounded down towards zero when converting it to a [Long] number of nanoseconds. + * For example, if the duration being added is `0.6.nanoseconds`, the reading doesn't advance because + * the duration value is rounded to zero nanoseconds. + * + * @throws IllegalStateException when the reading value overflows as the result of this operation. + */ + public operator fun plusAssign(duration: Duration) { + val delta = duration.toDouble(unit) + val longDelta = delta.toLong() + reading.set( + reading.get().let { currentReading -> + if (longDelta != Long.MIN_VALUE && longDelta != Long.MAX_VALUE) { + // when delta fits in long, add it as long + val newReading = currentReading + longDelta + if (currentReading xor longDelta >= 0 && currentReading xor newReading < 0) overflow(duration) + newReading + } else { + // when delta is greater than long, add it as double + val newReading = currentReading + delta + if (newReading > Long.MAX_VALUE || newReading < Long.MIN_VALUE) overflow(duration) + newReading.toLong() + } + } + ) + } + + private fun overflow(duration: Duration) { + throw IllegalStateException("FakeTimeSource will overflow if its reading ${reading}ns is advanced by $duration.") + } +} diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/RealCache.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/RealCache.kt new file mode 100644 index 00000000..3830f9b4 --- /dev/null +++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/RealCache.kt @@ -0,0 +1,250 @@ +package com.shabinder.common.caching + +import co.touchlab.stately.collections.IsoMutableMap +import co.touchlab.stately.collections.IsoMutableSet +import co.touchlab.stately.concurrency.AtomicReference +import co.touchlab.stately.concurrency.value +import kotlin.time.Duration +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +/** + * A Kotlin Multiplatform [Cache] implementation powered by touchlab/Stately. + * + * Two types of evictions are supported: + * + * 1. Time-based evictions (expiration) + * 2. Size-based evictions + * + * Time-based evictions are enabled by specifying [expireAfterWriteDuration] and/or [expireAfterAccessDuration]. + * When [expireAfterWriteDuration] is specified, entries will be automatically removed from the cache + * once a fixed duration has elapsed after the entry's creation + * or most recent replacement of its value. + * When [expireAfterAccessDuration] is specified, entries will be automatically removed from the cache + * once a fixed duration has elapsed after the entry's creation, + * the most recent replacement of its value, or its last access. + * + * Note that creation and replacement of an entry is also considered an access. + * + * Size-based evictions are enabled by specifying [maxSize]. When the size of the cache entries grows + * beyond [maxSize], least recently accessed entries will be evicted. + */ +internal class RealCache( + val expireAfterWriteDuration: Duration, + val expireAfterAccessDuration: Duration, + val maxSize: Long, + val timeSource: TimeSource, +) : Cache { + + private val cacheEntries = IsoMutableMap>() + + /** + * Whether to perform size based evictions. + */ + private val evictsBySize = maxSize >= 0 + + /** + * Whether to perform write-time based expiration. + */ + private val expiresAfterWrite = expireAfterWriteDuration.isFinite() + + /** + * Whether to perform access-time (both read and write) based expiration. + */ + private val expiresAfterAccess = expireAfterAccessDuration.isFinite() + + /** + * A queue of unique cache entries ordered by write time. + * Used for performing write-time based cache expiration. + */ + private val writeQueue: IsoMutableSet>? = + takeIf { expiresAfterWrite }?.let { + ReorderingIsoMutableSet() + } + + /** + * A queue of unique cache entries ordered by access time. + * Used for performing both write-time and read-time based cache expiration + * as well as size-based eviction. + * + * Note that a write is also considered an access. + */ + private val accessQueue: IsoMutableSet>? = + takeIf { expiresAfterAccess || evictsBySize }?.let { + ReorderingIsoMutableSet() + } + + override fun get(key: Key): Value? { + return cacheEntries[key]?.let { + if (it.isExpired()) { + // clean up expired entries and return null + expireEntries() + null + } else { + // update eviction metadata + recordRead(it) + it.value.get() + } + } + } + + override suspend fun get(key: Key, loader: suspend () -> Value): Value { + return cacheEntries[key]?.let { + if (it.isExpired()) { + // clean up expired entries + expireEntries() + null + } else { + // update eviction metadata + recordRead(it) + it.value.get() + } + } ?: loader().let { loadedValue -> + val existingValue = get(key) + if (existingValue != null) { + existingValue + } else { + put(key, loadedValue) + loadedValue + } + } + } + + override fun put(key: Key, value: Value) { + expireEntries() + + val existingEntry = cacheEntries[key] + if (existingEntry != null) { + // cache entry found + recordWrite(existingEntry) + existingEntry.value.set(value) + } else { + // create a new cache entry + val nowTimeMark = timeSource.markNow() + val newEntry = CacheEntry( + key = key, + value = AtomicReference(value), + accessTimeMark = AtomicReference(nowTimeMark), + writeTimeMark = AtomicReference(nowTimeMark), + ) + recordWrite(newEntry) + cacheEntries[key] = newEntry + } + + evictEntries() + } + + override fun invalidate(key: Key) { + expireEntries() + cacheEntries.remove(key)?.also { + writeQueue?.remove(it) + accessQueue?.remove(it) + } + } + + override fun invalidateAll() { + cacheEntries.clear() + writeQueue?.clear() + accessQueue?.clear() + } + + override fun asMap(): Map { + return cacheEntries.values.associate { entry -> + entry.key to entry.value.get() + } + } + + /** + * Remove all expired entries. + */ + private fun expireEntries() { + val queuesToProcess = listOfNotNull( + if (expiresAfterWrite) writeQueue else null, + if (expiresAfterAccess) accessQueue else null + ) + + queuesToProcess.forEach { queue -> + queue.access { + val iterator = queue.iterator() + for (entry in iterator) { + if (entry.isExpired()) { + cacheEntries.remove(entry.key) + // remove the entry from the current queue + iterator.remove() + } else { + // found unexpired entry, no need to look any further + break + } + } + } + } + } + + /** + * Check whether the [CacheEntry] has expired based on either access time or write time. + */ + private fun CacheEntry.isExpired(): Boolean { + return expiresAfterAccess && (accessTimeMark.get() + expireAfterAccessDuration).hasPassedNow() || + expiresAfterWrite && (writeTimeMark.get() + expireAfterWriteDuration).hasPassedNow() + } + + /** + * Evict least recently accessed entries until [cacheEntries] is no longer over capacity. + */ + private fun evictEntries() { + if (!evictsBySize) { + return + } + + checkNotNull(accessQueue) + + while (cacheEntries.size > maxSize) { + accessQueue.access { + it.firstOrNull()?.run { + cacheEntries.remove(key) + writeQueue?.remove(this) + accessQueue.remove(this) + } + } + } + } + + /** + * Update the eviction metadata on the [cacheEntry] which has just been read. + */ + private fun recordRead(cacheEntry: CacheEntry) { + if (expiresAfterAccess) { + val accessTimeMark = cacheEntry.accessTimeMark.value + cacheEntry.accessTimeMark.set(accessTimeMark + accessTimeMark.elapsedNow()) + } + accessQueue?.add(cacheEntry) + } + + /** + * Update the eviction metadata on the [CacheEntry] which is about to be written. + * Note that a write is also considered an access. + */ + private fun recordWrite(cacheEntry: CacheEntry) { + if (expiresAfterAccess) { + val accessTimeMark = cacheEntry.accessTimeMark.value + cacheEntry.accessTimeMark.set(accessTimeMark + accessTimeMark.elapsedNow()) + } + if (expiresAfterWrite) { + val writeTimeMark = cacheEntry.writeTimeMark.value + cacheEntry.writeTimeMark.set(writeTimeMark + writeTimeMark.elapsedNow()) + } + accessQueue?.add(cacheEntry) + writeQueue?.add(cacheEntry) + } +} + +/** + * A cache entry holds the [key] and [value] pair, + * along with the metadata needed to perform cache expiration and eviction. + */ +private class CacheEntry( + val key: Key, + val value: AtomicReference, + val accessTimeMark: AtomicReference, + val writeTimeMark: AtomicReference, +) diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/ReorderingIsoMutableSet.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/ReorderingIsoMutableSet.kt new file mode 100644 index 00000000..add167a1 --- /dev/null +++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/ReorderingIsoMutableSet.kt @@ -0,0 +1,17 @@ +package com.shabinder.common.caching + +import co.touchlab.stately.collections.IsoMutableSet + +/** + * A custom [IsoMutableSet] that updates the insertion order when an element is re-inserted, + * i.e. an inserted element will always be placed at the end + * regardless of whether the element already exists. + */ +internal class ReorderingIsoMutableSet : IsoMutableSet(), MutableSet { + override fun add(element: T): Boolean = access { + val exists = remove(element) + super.add(element) + // respect the contract "true if this set did not already contain the specified element" + !exists + } +} diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/Actions.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/Actions.kt index 9d3c9dde..808fd330 100644 --- a/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/Actions.kt +++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/Actions.kt @@ -42,8 +42,8 @@ interface Actions { } -private fun stubActions() = object :Actions{ - override val platformActions = object: PlatformActions{} +private fun stubActions() = object :Actions { + override val platformActions = StubPlatformActions override fun showPopUpMessage(string: String, long: Boolean) {} override fun setDownloadDirectoryAction() {} override fun queryActiveTracks() {} diff --git a/common/data-models/src/main/res/drawable/ic_download_arrow.xml b/common/data-models/src/main/res/drawable/ic_download_arrow.xml index 4a92ae81..deadedca 100644 --- a/common/data-models/src/main/res/drawable/ic_download_arrow.xml +++ b/common/data-models/src/main/res/drawable/ic_download_arrow.xml @@ -21,6 +21,6 @@ android:viewportHeight="24" android:tint="?attr/colorControlNormal"> diff --git a/common/data-models/src/main/res/drawable/ic_indian_rupee.xml b/common/data-models/src/main/res/drawable/ic_indian_rupee.xml new file mode 100644 index 00000000..637c6b56 --- /dev/null +++ b/common/data-models/src/main/res/drawable/ic_indian_rupee.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/common/data-models/src/main/res/drawable/ic_paypal_logo.xml b/common/data-models/src/main/res/drawable/ic_paypal_logo.xml new file mode 100644 index 00000000..933369b5 --- /dev/null +++ b/common/data-models/src/main/res/drawable/ic_paypal_logo.xml @@ -0,0 +1,5 @@ + + + + diff --git a/common/dependency-injection/build.gradle.kts b/common/dependency-injection/build.gradle.kts index dfdf80a1..fd0b94b8 100644 --- a/common/dependency-injection/build.gradle.kts +++ b/common/dependency-injection/build.gradle.kts @@ -41,7 +41,6 @@ kotlin { implementation(compose.materialIconsExtended) implementation(Extras.Android.razorpay) implementation(Extras.mp3agic) - //implementation(Extras.jaudioTagger) implementation("com.github.shabinder:storage-chooser:2.0.4.45") // implementation(files("$rootDir/libs/mobile-ffmpeg.aar")) } diff --git a/common/list/src/commonMain/kotlin/com/shabinder/common/list/integration/SpotiFlyerListImpl.kt b/common/list/src/commonMain/kotlin/com/shabinder/common/list/integration/SpotiFlyerListImpl.kt index fc7dab53..aeea3f48 100644 --- a/common/list/src/commonMain/kotlin/com/shabinder/common/list/integration/SpotiFlyerListImpl.kt +++ b/common/list/src/commonMain/kotlin/com/shabinder/common/list/integration/SpotiFlyerListImpl.kt @@ -28,6 +28,7 @@ import com.shabinder.common.list.store.SpotiFlyerListStore.Intent import com.shabinder.common.list.store.SpotiFlyerListStoreProvider import com.shabinder.common.list.store.getStore import com.shabinder.common.models.TrackDetails +import com.shabinder.common.caching.Cache internal class SpotiFlyerListImpl( componentContext: ComponentContext, @@ -49,6 +50,11 @@ internal class SpotiFlyerListImpl( ).provide() } + private val cache = Cache.Builder + .newBuilder() + .maximumCacheSize(150) + .build() + override val models: Value = store.asValue() override fun onDownloadAllClicked(trackList: List) { @@ -67,5 +73,9 @@ internal class SpotiFlyerListImpl( store.accept(Intent.RefreshTracksStatuses) } - override suspend fun loadImage(url: String): Picture = dir.loadImage(url) + override suspend fun loadImage(url: String): Picture { + return cache.get(url) { + dir.loadImage(url) + } + } } diff --git a/common/list/src/commonMain/kotlin/com/shabinder/common/list/store/SpotiFlyerListStoreProvider.kt b/common/list/src/commonMain/kotlin/com/shabinder/common/list/store/SpotiFlyerListStoreProvider.kt index eeef1b1d..b8b0e15f 100644 --- a/common/list/src/commonMain/kotlin/com/shabinder/common/list/store/SpotiFlyerListStoreProvider.kt +++ b/common/list/src/commonMain/kotlin/com/shabinder/common/list/store/SpotiFlyerListStoreProvider.kt @@ -44,6 +44,7 @@ internal class SpotiFlyerListStoreProvider( private val downloadProgressFlow: MutableSharedFlow> ) { val logger = getLogger() + fun provide(): SpotiFlyerListStore = object : SpotiFlyerListStore, diff --git a/common/main/src/commonMain/kotlin/com/shabinder/common/main/integration/SpotiFlyerMainImpl.kt b/common/main/src/commonMain/kotlin/com/shabinder/common/main/integration/SpotiFlyerMainImpl.kt index ce417a6c..554991a2 100644 --- a/common/main/src/commonMain/kotlin/com/shabinder/common/main/integration/SpotiFlyerMainImpl.kt +++ b/common/main/src/commonMain/kotlin/com/shabinder/common/main/integration/SpotiFlyerMainImpl.kt @@ -18,6 +18,7 @@ package com.shabinder.common.main.integration import co.touchlab.stately.ensureNeverFrozen import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.lifecycle.doOnDestroy import com.arkivanov.decompose.value.Value import com.shabinder.common.di.Picture import com.shabinder.common.di.utils.asValue @@ -30,6 +31,7 @@ import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent import com.shabinder.common.main.store.SpotiFlyerMainStoreProvider import com.shabinder.common.main.store.getStore import com.shabinder.common.models.methods +import com.shabinder.common.caching.Cache internal class SpotiFlyerMainImpl( componentContext: ComponentContext, @@ -38,6 +40,9 @@ internal class SpotiFlyerMainImpl( init { instanceKeeper.ensureNeverFrozen() + lifecycle.doOnDestroy { + cache.invalidateAll() + } } private val store = @@ -48,6 +53,11 @@ internal class SpotiFlyerMainImpl( ).provide() } + private val cache = Cache.Builder + .newBuilder() + .maximumCacheSize(20) + .build() + override val models: Value = store.asValue() override fun onLinkSearch(link: String) { @@ -63,5 +73,9 @@ internal class SpotiFlyerMainImpl( store.accept(Intent.SelectCategory(category)) } - override suspend fun loadImage(url: String): Picture = dir.loadImage(url) + override suspend fun loadImage(url: String): Picture { + return cache.get(url) { + dir.loadImage(url) + } + } } diff --git a/spotiflyer-ios b/spotiflyer-ios index 777e45a5..30c16869 160000 --- a/spotiflyer-ios +++ b/spotiflyer-ios @@ -1 +1 @@ -Subproject commit 777e45a555bcb7a8a0713705cbf159d05a45adb0 +Subproject commit 30c16869ebc81aace8a21cc0ecb0f0314f72b0bc diff --git a/web-app/build.gradle.kts b/web-app/build.gradle.kts index 7db58009..f7236985 100644 --- a/web-app/build.gradle.kts +++ b/web-app/build.gradle.kts @@ -16,14 +16,12 @@ plugins { kotlin("js") - //id("org.jetbrains.kotlin.js") version "1.4.31" } group = "com.shabinder" version = "0.1" repositories { - jcenter() mavenCentral() maven(url = "https://dl.bintray.com/kotlin/kotlin-js-wrappers") }