mirror of
https://github.com/Shabinder/SpotiFlyer.git
synced 2024-12-22 12:47:54 +01:00
In-Memory Caching for smoother performance, Donation Dialog, Dep Update
This commit is contained in:
parent
661dd1ada4
commit
60b2f04780
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,3 +6,4 @@ build/
|
||||
.gradle/
|
||||
terraform.tfvars
|
||||
.terraform/
|
||||
/spotiflyer-ios/Pods/
|
||||
|
@ -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")
|
||||
|
@ -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")
|
||||
|
@ -3,5 +3,5 @@ plugins {
|
||||
}
|
||||
|
||||
repositories {
|
||||
jcenter()
|
||||
mavenCentral()
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
)*/
|
||||
}
|
||||
}
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.shabinder.common.uikit
|
@ -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
|
||||
)
|
@ -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
|
||||
)
|
||||
}
|
||||
|
@ -0,0 +1,2 @@
|
||||
package com.shabinder.common.uikit.dialogs
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<in Key : Any, Value : Any> {
|
||||
|
||||
/**
|
||||
* 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<in Key, Value>
|
||||
|
||||
/**
|
||||
* 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 <K : Any, V : Any> build(): Cache<K, V>
|
||||
|
||||
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 <K : Any, V : Any> build(): Cache<K, V> {
|
||||
return RealCache(
|
||||
expireAfterWriteDuration,
|
||||
expireAfterAccessDuration,
|
||||
maxSize,
|
||||
fakeTimeSource ?: TimeSource.Monotonic,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal const val UNSET_LONG: Long = -1
|
||||
}
|
||||
}
|
@ -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.")
|
||||
}
|
||||
}
|
@ -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<Key : Any, Value : Any>(
|
||||
val expireAfterWriteDuration: Duration,
|
||||
val expireAfterAccessDuration: Duration,
|
||||
val maxSize: Long,
|
||||
val timeSource: TimeSource,
|
||||
) : Cache<Key, Value> {
|
||||
|
||||
private val cacheEntries = IsoMutableMap<Key, CacheEntry<Key, Value>>()
|
||||
|
||||
/**
|
||||
* 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<CacheEntry<Key, Value>>? =
|
||||
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<CacheEntry<Key, Value>>? =
|
||||
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<in Key, Value> {
|
||||
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<Key, Value>.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<Key, Value>) {
|
||||
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<Key, Value>) {
|
||||
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<Key : Any, Value : Any>(
|
||||
val key: Key,
|
||||
val value: AtomicReference<Value>,
|
||||
val accessTimeMark: AtomicReference<TimeMark>,
|
||||
val writeTimeMark: AtomicReference<TimeMark>,
|
||||
)
|
@ -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<T> : IsoMutableSet<T>(), MutableSet<T> {
|
||||
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
|
||||
}
|
||||
}
|
@ -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() {}
|
||||
|
@ -21,6 +21,6 @@
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,4c4.41,0 8,3.59 8,8s-3.59,8 -8,8s-8,-3.59 -8,-8S7.59,4 12,4M12,2C6.48,2 2,6.48 2,12c0,5.52 4.48,10 10,10c5.52,0 10,-4.48 10,-10C22,6.48 17.52,2 12,2L12,2zM13,12l0,-4h-2l0,4H8l4,4l4,-4H13z"/>
|
||||
</vector>
|
||||
|
@ -0,0 +1,6 @@
|
||||
<vector android:height="24dp" android:viewportHeight="456"
|
||||
android:viewportWidth="456" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M61.179,282h-41.2c-6,0 -10.9,4.9 -10.9,10.9v152.2c0,6 4.9,10.9 10.9,10.9h41.2c6,0 10.9,-4.9 10.9,-10.9V292.9C72.079,286.9 67.179,282 61.179,282z"/>
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M443.179,294.4c-0.3,-0.4 -0.6,-0.8 -0.8,-1.2c-6.1,-6.8 -16.4,-7.7 -23.6,-2.1c-20,16.3 -49.2,39.8 -68.1,55.1c-16.7,13.3 -37.3,21 -58.7,21.8l-51.2,1.7c-9.4,0.3 -18.2,-4.5 -23,-12.6l-5.7,-9.7c-1.5,-2.5 -2.5,-5.3 -3.1,-8.2c-2.6,-13.9 6.5,-27.4 20.4,-30l52.9,-10c8.5,-1.7 14.3,-9.7 13.3,-18.3c-1,-8.2 -8,-14.3 -16.2,-14.4c-0.3,0 -0.7,0 -0.8,0c-0.4,0 -0.9,0 -1.3,0l-71.2,-2.9c-24.7,-0.9 -46,3.9 -71.2,16.1l-42.8,20.7v114l35.9,-6.6c0.1,-0.1 0.2,-0.1 0.3,-0.1c13.9,-2 23.1,-2.9 38.2,-2.2l107.5,5c33.7,1.4 66.8,-9.7 92.7,-31.3l74.4,-61.7C447.979,311.7 448.879,301.3 443.179,294.4z"/>
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M307.379,0c-61.2,0.1 -110.7,49.7 -110.8,110.8c0,0.1 0,0.1 0,0.1c0,61.2 49.7,110.8 110.9,110.8s110.8,-49.7 110.8,-110.9S368.579,0 307.379,0zM333.079,80h13.4c5.5,0 10,4.5 10,10s-4.5,10 -10,10h-13.4c-2,7.8 -6,14.9 -11.7,20.6c-7.5,7.5 -17.5,11.9 -28.1,12.5l37.7,35.7c4,3.8 4.2,10.1 0.4,14.1s-10.1,4.2 -14.1,0.4l-55.9,-52.9c-1.9,-1.9 -3.1,-4.5 -3.1,-7.2c-0.1,-5.6 4.4,-10.1 10,-10.2h22.4c6.2,0.1 12.2,-2.3 16.7,-6.7c1.8,-1.9 3.3,-4 4.6,-6.3h-43.7c-5.5,0 -10,-4.5 -10,-10s4.5,-10 10,-10h43.7c-3.7,-8 -11.9,-14 -21.3,-14h-22.4c-5.5,0 -10,-4.5 -10,-10s4.5,-10 10,-10h78.2c5.5,0 10,4.5 10,10s-4.5,10 -10,10h-19.2C330.079,70.3 331.979,75 333.079,80z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:viewportHeight="435.505"
|
||||
android:viewportWidth="435.505" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M403.496,101.917c-4.104,-5.073 -8.877,-9.705 -14.166,-13.839c0.707,13.117 -0.508,27.092 -3.668,41.884c-8.627,40.413 -29.256,74.754 -59.656,99.304c-30.375,24.533 -68.305,37.502 -109.686,37.502h-60.344l-19.533,91.512c-3.836,17.959 -19.943,30.99 -38.303,30.99H70.938l-4.898,22.484c-1.258,5.79 0.17,11.839 3.887,16.453c3.715,4.614 9.324,7.298 15.25,7.298h66.498c9.24,0 17.225,-6.459 19.152,-15.495L193.667,313h76.188c36.854,0 70.527,-11.464 97.384,-33.152c26.869,-21.697 45.129,-52.186 52.807,-88.162C427.822,155.309 422.253,125.106 403.496,101.917z"/>
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M117.292,354.191l22.84,-107.008h76.188c36.852,0 70.527,-11.465 97.383,-33.154c26.867,-21.697 45.129,-52.186 52.809,-88.161c7.773,-36.378 2.207,-66.58 -16.553,-89.769C331.952,13.832 301.17,0 269.633,0H103.639c-9.209,0 -17.174,6.417 -19.135,15.414L12.505,345.938c-1.26,5.789 0.168,11.838 3.887,16.453c3.713,4.613 9.32,7.296 15.248,7.296h66.5C107.38,369.687 115.36,363.229 117.292,354.191zM178.235,75.291h52.229c12.287,0 23.274,5.149 30.145,14.129c7.297,9.539 9.431,22.729 5.853,36.188c-0.047,0.171 -0.088,0.342 -0.131,0.516c-6.57,27.73 -33.892,50.291 -60.898,50.291h-50.05L178.235,75.291z"/>
|
||||
</vector>
|
@ -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"))
|
||||
}
|
||||
|
@ -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<String, Picture>()
|
||||
|
||||
override val models: Value<State> = store.asValue()
|
||||
|
||||
override fun onDownloadAllClicked(trackList: List<TrackDetails>) {
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -44,6 +44,7 @@ internal class SpotiFlyerListStoreProvider(
|
||||
private val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
|
||||
) {
|
||||
val logger = getLogger()
|
||||
|
||||
fun provide(): SpotiFlyerListStore =
|
||||
object :
|
||||
SpotiFlyerListStore,
|
||||
|
@ -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<String, Picture>()
|
||||
|
||||
override val models: Value<State> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
Subproject commit 777e45a555bcb7a8a0713705cbf159d05a45adb0
|
||||
Subproject commit 30c16869ebc81aace8a21cc0ecb0f0314f72b0bc
|
@ -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")
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user