(WIP)Android SAF

This commit is contained in:
shabinder 2021-05-14 00:47:54 +05:30
parent a3e9b7c3c1
commit 96fdd52ef4
37 changed files with 716 additions and 278 deletions

View File

@ -120,12 +120,6 @@ dependencies {
implementation(MVIKotlin.mvikotlinLogging) implementation(MVIKotlin.mvikotlinLogging)
implementation(MVIKotlin.mvikotlinTimeTravel) implementation(MVIKotlin.mvikotlinTimeTravel)
// Firebase
implementation(platform("com.google.firebase:firebase-bom:27.1.0"))
implementation("com.google.firebase:firebase-analytics-ktx")
implementation("com.google.firebase:firebase-crashlytics-ktx")
implementation("com.google.firebase:firebase-perf-ktx")
// Extras // Extras
Extras.Android.apply { Extras.Android.apply {
implementation(Acra.notification) implementation(Acra.notification)
@ -134,8 +128,8 @@ dependencies {
implementation(matomo) implementation(matomo)
} }
implementation("com.jakewharton.timber:timber:4.7.1")
implementation("dev.icerock.moko:parcelize:0.6.1") 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.9.1") implementation("com.google.accompanist:accompanist-insets:0.9.1")
// Test // Test

View File

@ -29,8 +29,29 @@ import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.logger.Level import org.koin.core.logger.Level
import org.matomo.sdk.Matomo
import org.matomo.sdk.Tracker
import org.matomo.sdk.TrackerBuilder
import timber.log.Timber
import timber.log.Timber.DebugTree
class App: Application(), KoinComponent { class App: Application(), KoinComponent {
val tracker: Tracker by lazy {
TrackerBuilder.createDefault(
"https://kind-grasshopper-73.telebit.io/matomo/matomo.php", 1)
.build(Matomo.getInstance(this)).apply {
if (BuildConfig.DEBUG) {
Timber.plant(DebugTree())
addTrackingCallback {
Timber.d(it.toMap().toString())
it
}
}
}
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()

View File

@ -39,13 +39,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.jetbrains.rememberRootComponent import com.arkivanov.decompose.extensions.compose.jetbrains.rememberRootComponent
import com.arkivanov.mvikotlin.logging.store.LoggingStoreFactory import com.arkivanov.mvikotlin.logging.store.LoggingStoreFactory
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
import com.codekidlabs.storagechooser.R import com.github.k1rakishou.fsaf.FileChooser
import com.codekidlabs.storagechooser.StorageChooser import com.github.k1rakishou.fsaf.FileManager
import com.github.k1rakishou.fsaf.callback.directory.DirectoryChooserCallback
import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsPadding import com.google.accompanist.insets.navigationBarsPadding
import com.google.accompanist.insets.statusBarsHeight import com.google.accompanist.insets.statusBarsHeight
@ -56,22 +58,23 @@ import com.shabinder.common.models.Actions
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.PlatformActions import com.shabinder.common.models.PlatformActions
import com.shabinder.common.models.PlatformActions.Companion.SharedPreferencesKey import com.shabinder.common.models.PlatformActions.Companion.SharedPreferencesKey
import com.shabinder.common.models.SpotiFlyerBaseDir
import com.shabinder.common.models.Status
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.methods
import com.shabinder.common.root.SpotiFlyerRoot import com.shabinder.common.root.SpotiFlyerRoot
import com.shabinder.common.root.SpotiFlyerRoot.Analytics
import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks
import com.shabinder.common.uikit.* import com.shabinder.common.uikit.*
import com.shabinder.spotiflyer.utils.*
import com.shabinder.common.models.Status
import com.shabinder.common.models.methods
import com.shabinder.spotiflyer.ui.NetworkDialog import com.shabinder.spotiflyer.ui.NetworkDialog
import com.shabinder.spotiflyer.ui.PermissionDialog import com.shabinder.spotiflyer.ui.PermissionDialog
import com.shabinder.spotiflyer.utils.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.matomo.sdk.extra.TrackHelper
import java.io.File import java.io.File
const val disableDozeCode = 1223
@ExperimentalAnimationApi @ExperimentalAnimationApi
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -85,6 +88,7 @@ class MainActivity : ComponentActivity() {
private lateinit var updateUIReceiver: BroadcastReceiver private lateinit var updateUIReceiver: BroadcastReceiver
private lateinit var queryReceiver: BroadcastReceiver private lateinit var queryReceiver: BroadcastReceiver
private val internetAvailability by lazy { ConnectionLiveData(applicationContext) } private val internetAvailability by lazy { ConnectionLiveData(applicationContext) }
private val tracker get() = (application as App).tracker
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -118,7 +122,8 @@ class MainActivity : ComponentActivity() {
PermissionDialog( PermissionDialog(
permissionGranted.value, permissionGranted.value,
{ requestStoragePermission() }, { requestStoragePermission() },
{ disableDozeMode(disableDozeCode) } { disableDozeMode(disableDozeCode) },
dir::enableAnalytics
) )
} }
} }
@ -130,6 +135,10 @@ class MainActivity : ComponentActivity() {
private fun initialise() { private fun initialise() {
checkIfLatestVersion() checkIfLatestVersion()
handleIntentFromExternalActivity() handleIntentFromExternalActivity()
if(dir.isAnalyticsEnabled){
// Download/App Install Event
TrackHelper.track().download().with(tracker)
}
} }
@Composable @Composable
@ -139,38 +148,34 @@ class MainActivity : ComponentActivity() {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
private fun setUpOnPrefClickListener() { private fun setUpOnPrefClickListener() {
// Initialize Builder /*Get User Permission to access External SD*//*
val chooser = StorageChooser.Builder() val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
.withActivity(this) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.withFragmentManager(fragmentManager) addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
.withMemoryBar(true) addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
.setTheme(StorageChooser.Theme(applicationContext).apply { }
scheme = applicationContext.resources.getIntArray(R.array.default_dark) startActivityForResult(intent, externalSDWriteAccess)*/
val fileChooser = FileChooser(applicationContext)
fileChooser.openChooseDirectoryDialog(object : DirectoryChooserCallback() {
override fun onResult(uri: Uri) {
println("treeUri = $uri")
// Can be only used using SAF
contentResolver.takePersistableUriPermission(uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
val treeDocumentFile = DocumentFile.fromTreeUri(applicationContext, uri)
dir.setDownloadDirectory(uri)
showPopUpMessage("New Download Directory Set")
GlobalScope.launch {
dir.createDirectories()
}
}
override fun onCancel(reason: String) {
println("Canceled by user")
}
}) })
.setDialogTitle("Set Download Directory")
.allowCustomPath(true)
.setType(StorageChooser.DIRECTORY_CHOOSER)
.build()
// get path that the user has chosen
chooser.setOnSelectListener { path ->
Log.d("Setting Base Path", path)
val f = File(path)
if (f.canWrite()) {
// hell yeah :)
dir.setDownloadDirectory(path)
showPopUpMessage(
"Download Directory Set to:\n${dir.defaultDir()} "
)
}else{
showPopUpMessage(
"NO WRITE ACCESS on \n$path ,\nReverting Back to Previous"
)
}
}
// Show dialog whenever you want by
chooser.show()
} }
private fun showPopUpMessage(string: String, long: Boolean = false) { private fun showPopUpMessage(string: String, long: Boolean = false) {
@ -193,12 +198,13 @@ class MainActivity : ComponentActivity() {
override val storeFactory = LoggingStoreFactory(DefaultStoreFactory) override val storeFactory = LoggingStoreFactory(DefaultStoreFactory)
override val database = this@MainActivity.dir.db override val database = this@MainActivity.dir.db
override val fetchPlatformQueryResult = this@MainActivity.fetcher override val fetchPlatformQueryResult = this@MainActivity.fetcher
@SuppressLint("StaticFieldLeak")
override val directories: Dir = this@MainActivity.dir override val directories: Dir = this@MainActivity.dir
override val downloadProgressReport: MutableSharedFlow<HashMap<String, DownloadStatus>> = trackStatusFlow override val downloadProgressReport: MutableSharedFlow<HashMap<String, DownloadStatus>> = trackStatusFlow
override val actions = object: Actions { override val actions = object: Actions {
override val platformActions = object : PlatformActions { override val platformActions = object : PlatformActions {
override val imageCacheDir: String = applicationContext.cacheDir.absolutePath + File.separator override val imageCacheDir: File = applicationContext.cacheDir
override val sharedPreferences = applicationContext.getSharedPreferences(SharedPreferencesKey, override val sharedPreferences = applicationContext.getSharedPreferences(SharedPreferencesKey,
MODE_PRIVATE MODE_PRIVATE
) )
@ -264,6 +270,37 @@ class MainActivity : ComponentActivity() {
override val isInternetAvailable get() = internetAvailability.value ?: true override val isInternetAvailable get() = internetAvailability.value ?: true
} }
override val analytics = object: Analytics {
override fun appLaunchEvent() {
TrackHelper.track()
.event("events","App_Launch")
.name("App Launch").with(tracker)
}
override fun homeScreenVisit() {
if(dir.isAnalyticsEnabled){
// HomeScreen Visit Event
TrackHelper.track().screen("/main_activity/home_screen")
.title("HomeScreen").with(tracker)
}
}
override fun listScreenVisit() {
if(dir.isAnalyticsEnabled){
// ListScreen Visit Event
TrackHelper.track().screen("/main_activity/list_screen")
.title("ListScreen").with(tracker)
}
}
override fun donationDialogVisit() {
if (dir.isAnalyticsEnabled) {
// Donation Dialog Open Event
TrackHelper.track().screen("/main_activity/donation_dialog")
.title("DonationDialog").with(tracker)
}
}
}
} }
) )
@ -271,20 +308,45 @@ class MainActivity : ComponentActivity() {
@SuppressLint("ObsoleteSdkInt") @SuppressLint("ObsoleteSdkInt")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
if (requestCode == disableDozeCode) { when(requestCode) {
disableDozeCode -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = val pm =
getSystemService(Context.POWER_SERVICE) as PowerManager getSystemService(Context.POWER_SERVICE) as PowerManager
val isIgnoringBatteryOptimizations = val isIgnoringBatteryOptimizations =
pm.isIgnoringBatteryOptimizations(packageName) pm.isIgnoringBatteryOptimizations(packageName)
if (isIgnoringBatteryOptimizations) { if (isIgnoringBatteryOptimizations) {
// Ignoring battery optimization // Already Ignoring battery optimization
permissionGranted.value = true permissionGranted.value = true
} else { } else {
disableDozeMode(disableDozeCode)//Again Ask For Permission!! //Again Ask For Permission!!
disableDozeMode(disableDozeCode)
} }
} }
} }
externalSDWriteAccess -> {
// Can be only used using SAF
/*if (resultCode == RESULT_OK) {
val treeUri: Uri? = data?.data
if (treeUri == null){
showPopUpMessage("Some Error Occurred While Setting New Download Directory")
}else {
// Persistently save READ & WRITE Access to whole Selected Directory Tree
contentResolver.takePersistableUriPermission(treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
dir.setDownloadDirectory(com.shabinder.common.models.File(
DocumentFile.fromTreeUri(applicationContext,treeUri)?.createDirectory("SpotiFlyer")!!)
)
showPopUpMessage("New Download Directory Set")
GlobalScope.launch {
dir.createDirectories()
}
}
}*/
}
}
} }
/* /*
@ -380,4 +442,9 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
companion object {
const val disableDozeCode = 1223
const val externalSDWriteAccess = 1224
}
} }

View File

@ -8,11 +8,13 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.AlertDialog import androidx.compose.material.AlertDialog
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TextButton import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Insights
import androidx.compose.material.icons.rounded.SdStorage import androidx.compose.material.icons.rounded.SdStorage
import androidx.compose.material.icons.rounded.SystemSecurityUpdate import androidx.compose.material.icons.rounded.SystemSecurityUpdate
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -28,6 +30,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogProperties
import com.shabinder.common.uikit.SpotiFlyerShapes import com.shabinder.common.uikit.SpotiFlyerShapes
import com.shabinder.common.uikit.SpotiFlyerTypography import com.shabinder.common.uikit.SpotiFlyerTypography
import com.shabinder.common.uikit.colorPrimary import com.shabinder.common.uikit.colorPrimary
@ -38,13 +41,50 @@ import kotlinx.coroutines.delay
fun PermissionDialog( fun PermissionDialog(
permissionGranted: Boolean, permissionGranted: Boolean,
requestStoragePermission:() -> Unit, requestStoragePermission:() -> Unit,
disableDozeMode:() -> Unit disableDozeMode:() -> Unit,
enableAnalytics:() -> Unit
){ ){
var askForPermission by remember { mutableStateOf(false) } var askForPermission by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(2000) delay(2000)
askForPermission = true askForPermission = true
} }
// Analytics Permission Dialog
var askForAnalyticsPermission by remember { mutableStateOf(false) }
AnimatedVisibility(askForAnalyticsPermission) {
AlertDialog(
onDismissRequest = {
askForAnalyticsPermission = false
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Insights,"Analytics",Modifier.size(52.dp))
Spacer(Modifier.padding(horizontal = 4.dp))
Text("Grant Analytics Access",style = SpotiFlyerTypography.h5,textAlign = TextAlign.Center)
}
},
backgroundColor = Color.DarkGray,
buttons = {
TextButton(
{
askForAnalyticsPermission = false
enableAnalytics()
},
Modifier.padding(bottom = 16.dp, start = 16.dp, end = 16.dp).fillMaxWidth()
.background(colorPrimary, shape = SpotiFlyerShapes.medium)
.padding(horizontal = 8.dp),
) {
Text("Sure!",color = Color.Black,fontSize = 18.sp,textAlign = TextAlign.Center)
}
},
text = {
Text("Your Data is Anonymized and will never be shared with any 3rd party service",style = SpotiFlyerTypography.body2,textAlign = TextAlign.Center)
},
properties = DialogProperties(dismissOnBackPress = true,dismissOnClickOutside = false)
)
}
AnimatedVisibility( AnimatedVisibility(
askForPermission && !permissionGranted askForPermission && !permissionGranted
) { ) {
@ -55,6 +95,7 @@ fun PermissionDialog(
{ {
requestStoragePermission() requestStoragePermission()
disableDozeMode() disableDozeMode()
askForAnalyticsPermission = true
}, },
Modifier.padding(bottom = 16.dp, start = 16.dp, end = 16.dp).fillMaxWidth() Modifier.padding(bottom = 16.dp, start = 16.dp, end = 16.dp).fillMaxWidth()
.background(colorPrimary, shape = SpotiFlyerShapes.medium) .background(colorPrimary, shape = SpotiFlyerShapes.medium)
@ -100,6 +141,23 @@ fun PermissionDialog(
) )
} }
} }
Row(
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.Insights,"Analytics")
Spacer(modifier = Modifier.padding(start = 16.dp))
Column {
Text(
text = "Analytics",
style = SpotiFlyerTypography.h6.copy(fontWeight = FontWeight.SemiBold)
)
Text(
text = "Share Analytics Data (optional) with App Devs (Self-Hosted), It will never be used/shared/sold to any third party service.",
style = SpotiFlyerTypography.subtitle2,
)
}
}
} }
} }
) )

View File

@ -1,62 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- <color name="colorPrimary">#2d6c55</color>
<color name="colorPrimaryDark">#235644</color>
<color name="colorAccent">#ff9c40</color>
<color name="colorCyanListClick">#d6c8f5f3</color>
<color name="colorListDivider">#89606060</color>
<color name="pathLayoutBgColor">#70ffffff</color>
<color name="chevronBgColor">#50ffffff</color>
<color name="inactiveGradientColor">#53000000</color>
&lt;!&ndash; dialog colors &ndash;&gt;
<color name="memory_status_color">#de6565</color>
<color name="memory_bar_color">#7bde65</color>
<color name="new_folder_color">#c14b84</color>
<color name="select_color">#6b3fa1</color>
<color name="cancel_color">#3fa19f</color>-->
<array name="default_light">
<!-- Overview -->
<item>@color/colorPrimary</item> <!-- Top Header bg -->
<item>@android:color/white</item> <!-- header text -->
<item>@android:color/white</item> <!-- list bg -->
<item>@android:color/black</item> <!-- storage list name text -->
<item>@color/colorPrimary</item> <!-- free space text -->
<item>@color/colorAccent</item> <!-- memory bar -->
<!-- secondary dialog colors -->
<item>@color/colorPrimary</item> <!-- address bar bg -->
<item>@android:color/white</item> <!-- list bg -->
<item>@android:color/black</item> <!-- list text -->
<item>@android:color/white</item> <!-- address bar tint -->
<item>@color/chevronBgColor</item> <!-- new folder hint tint -->
<item>#da6c6c</item> <!-- select button color -->
<item>#da6c6c</item> <!-- new folder layour bg -->
<item>#da6c6c</item> <!-- new folder layour bg -->
<item>@color/colorPrimary</item> <!-- new folder layour bg -->
</array>
<array name="default_dark">
<!-- Overview -->
<item>@color/colorPrimary</item> <!-- Top Header bg -->
<item>@android:color/white</item> <!-- header text -->
<item>@android:color/black</item> <!-- list bg -->
<item>@android:color/white</item> <!-- storage list name text -->
<item>#da6c6c</item> <!-- free space text -->
<item>@color/colorPrimary</item> <!-- memory bar -->
<!-- secondary dialog colors -->
<item>@color/colorPrimary</item> <!-- address bar bg -->
<item>@android:color/black</item> <!-- list bg -->
<item>@android:color/white</item> <!-- list text -->
<item>@android:color/white</item> <!-- address bar tint -->
<item>@color/grey</item> <!-- new folder hint tint -->
<item>#da6c6c</item> <!-- select button color -->
<item>#da6c6c</item> <!-- new folder layour bg -->
<item>#da6c6c</item> <!-- new multi fab -->
<item>#da6c6c</item> <!-- new multi fab -->
</array>
</resources>

View File

@ -102,7 +102,7 @@ fun SpotiFlyerMainContent(component: SpotiFlyerMain) {
) )
when (model.selectedCategory) { when (model.selectedCategory) {
HomeCategory.About -> AboutColumn() HomeCategory.About -> AboutColumn { component.analytics.donationDialogVisit() }
HomeCategory.History -> HistoryColumn( HomeCategory.History -> HistoryColumn(
model.records.sortedByDescending { it.id }, model.records.sortedByDescending { it.id },
component::loadImage, component::loadImage,
@ -221,7 +221,10 @@ fun SearchPanel(
} }
@Composable @Composable
fun AboutColumn(modifier: Modifier = Modifier) { fun AboutColumn(
modifier: Modifier = Modifier,
donationDialogOpenEvent:() -> Unit
) {
Box { Box {
val stateVertical = rememberScrollState(0) val stateVertical = rememberScrollState(0)
@ -331,14 +334,18 @@ fun AboutColumn(modifier: Modifier = Modifier) {
var isDonationDialogVisible by remember { mutableStateOf(false) } var isDonationDialogVisible by remember { mutableStateOf(false) }
DonationDialog( DonationDialog(
isDonationDialogVisible isDonationDialogVisible,
) { onDismiss = {
isDonationDialogVisible = false isDonationDialogVisible = false
} }
)
Row( Row(
modifier = modifier.fillMaxWidth().padding(vertical = 6.dp) modifier = modifier.fillMaxWidth().padding(vertical = 6.dp)
.clickable(onClick = { isDonationDialogVisible = true }), .clickable(onClick = {
isDonationDialogVisible = true
donationDialogOpenEvent()
}),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(Icons.Rounded.CardGiftcard, "Support Developer") Icon(Icons.Rounded.CardGiftcard, "Support Developer")

View File

@ -45,5 +45,11 @@ kotlin {
implementation("co.touchlab:stately-iso-collections:$statelyIsoVersion") implementation("co.touchlab:stately-iso-collections:$statelyIsoVersion")
} }
} }
androidMain {
dependencies {
api("com.github.K1rakishou:Fuck-Storage-Access-Framework:v1.1")
api("androidx.documentfile:documentfile:1.0.1")
}
}
} }
} }

View File

@ -0,0 +1,8 @@
package com.shabinder.common.models
import com.github.k1rakishou.fsaf.file.AbstractFile
// Use Storage Access Framework `SAF`
actual data class File(
val documentFile: AbstractFile?
)

View File

@ -8,7 +8,7 @@ actual interface PlatformActions {
const val SharedPreferencesKey = "configurations" const val SharedPreferencesKey = "configurations"
} }
val imageCacheDir: String val imageCacheDir: java.io.File
val sharedPreferences: SharedPreferences? val sharedPreferences: SharedPreferences?
@ -18,7 +18,7 @@ actual interface PlatformActions {
} }
actual val StubPlatformActions = object: PlatformActions { actual val StubPlatformActions = object: PlatformActions {
override val imageCacheDir: String = "" override val imageCacheDir = java.io.File("/")
override val sharedPreferences: SharedPreferences? = null override val sharedPreferences: SharedPreferences? = null

View File

@ -0,0 +1,18 @@
package com.shabinder.common.models
import android.net.Uri
import com.github.k1rakishou.fsaf.manager.base_directory.BaseDirectory
import java.io.File
class SpotiFlyerBaseDir(
private val getDirType: ()-> ActiveBaseDirType,
private val getJavaFile: ()-> File?,
private val getSAFUri: ()-> Uri?
): BaseDirectory() {
override fun currentActiveBaseDirType(): ActiveBaseDirType = getDirType()
override fun getDirFile(): File? = getJavaFile()
override fun getDirUri(): Uri? = getSAFUri()
}

View File

@ -32,12 +32,12 @@ data class TrackDetails(
var comment: String? = null, var comment: String? = null,
var lyrics: String? = null, var lyrics: String? = null,
var trackUrl: String? = null, var trackUrl: String? = null,
var albumArtPath: String, var albumArtPath: String, // UriString in Android
var albumArtURL: String, var albumArtURL: String,
var source: Source, var source: Source,
val progress: Int = 2, val progress: Int = 2,
val downloaded: DownloadStatus = DownloadStatus.NotDownloaded, val downloaded: DownloadStatus = DownloadStatus.NotDownloaded,
var outputFilePath: String, var outputFilePath: String, // UriString in Android
var videoID: String? = null, var videoID: String? = null,
) : Parcelable ) : Parcelable

View File

@ -0,0 +1,3 @@
package com.shabinder.common.models
expect class File

View File

@ -0,0 +1,3 @@
package com.shabinder.common.models
actual typealias File = java.io.File

View File

@ -0,0 +1,5 @@
package com.shabinder.common.models
actual data class File(
val path: String
)

View File

@ -0,0 +1,5 @@
package com.shabinder.common.models
actual data class File(
val path: String
)

View File

@ -41,7 +41,6 @@ kotlin {
dependencies { dependencies {
implementation(compose.materialIconsExtended) implementation(compose.materialIconsExtended)
implementation(Extras.mp3agic) implementation(Extras.mp3agic)
implementation("com.github.shabinder:storage-chooser:2.0.4.45")
// implementation(files("$rootDir/libs/mobile-ffmpeg.aar")) // implementation(files("$rootDir/libs/mobile-ffmpeg.aar"))
} }
} }

View File

@ -16,14 +16,26 @@
package com.shabinder.common.di package com.shabinder.common.di
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Environment import android.os.Environment
import android.util.Log
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.documentfile.provider.DocumentFile
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.github.k1rakishou.fsaf.FileManager
import com.github.k1rakishou.fsaf.file.AbstractFile
import com.github.k1rakishou.fsaf.file.DirectorySegment
import com.github.k1rakishou.fsaf.file.FileSegment
import com.github.k1rakishou.fsaf.manager.base_directory.BaseDirectory
import com.mpatric.mp3agic.Mp3File import com.mpatric.mp3agic.Mp3File
import com.russhwolf.settings.Settings import com.russhwolf.settings.Settings
import com.shabinder.common.database.SpotiFlyerDatabase import com.shabinder.common.database.SpotiFlyerDatabase
import com.shabinder.common.di.utils.removeIllegalChars
import com.shabinder.common.models.File
import com.shabinder.common.models.SpotiFlyerBaseDir
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.methods import com.shabinder.common.models.methods
import com.shabinder.database.Database import com.shabinder.database.Database
@ -31,38 +43,110 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
/*
* Ignore Deprecation
* Deprecation is only a Suggestion P-)
* */
@Suppress("DEPRECATION")
actual class Dir actual constructor( actual class Dir actual constructor(
private val logger: Kermit, private val logger: Kermit,
private val settings: Settings, private val settings: Settings,
private val spotiFlyerDatabase: SpotiFlyerDatabase, private val spotiFlyerDatabase: SpotiFlyerDatabase,
) { ): KoinComponent {
companion object {
const val DirKey = "downloadDir" private val context: Context = get()
val fileManager = FileManager(context)
init {
fileManager.registerBaseDir<SpotiFlyerBaseDir>(SpotiFlyerBaseDir({ getDirType() },
getJavaFile = {
java.io.File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC).toString()
+ "/SpotiFlyer/"
)
},
getSAFUri = { null }
))
} }
actual fun setDownloadDirectory(newBasePath:String) = settings.putString(DirKey,newBasePath) companion object {
const val DirKey = "downloadDir"
const val AnalyticsKey = "analytics"
}
@Suppress("DEPRECATION") /*
private val defaultBaseDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC).toString() * Do we have Analytics Permission?
* - Defaults to `False`
* */
actual val isAnalyticsEnabled get() = settings.getBooleanOrNull(AnalyticsKey) ?: false
actual fun fileSeparator(): String = File.separator actual fun enableAnalytics() {
settings.putBoolean(AnalyticsKey,true)
}
actual fun imageCacheDir(): String = methods.value.platformActions.imageCacheDir private fun getDirType() :BaseDirectory.ActiveBaseDirType{
return if(settings.getStringOrNull(DirKey) == null) {
// Default Dir
BaseDirectory.ActiveBaseDirType.JavaFileBaseDir
}else {
// User Updated Dir
BaseDirectory.ActiveBaseDirType.SafBaseDir
}
}
actual fun setDownloadDirectory(newBasePath:File) = settings.putString(
DirKey,
newBasePath.documentFile?.getFullPath()!!
)
fun setDownloadDirectory(treeUri:Uri) {
fileManager.registerBaseDir<SpotiFlyerBaseDir>(SpotiFlyerBaseDir(
{ getDirType() },
getJavaFile = {
null
},
getSAFUri = {
treeUri
}
))
}
@Suppress("DEPRECATION")// By Default Save Files to /Music/SpotiFlyer/
private val defaultBaseDir = SpotiFlyerBaseDir({ getDirType() },
getJavaFile = {java.io.File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC).toString() + "/SpotiFlyer/")},
getSAFUri = { null }
)
// Image Cache Path
// We Will Handling Image relating operations using java.io.File (reason: Faster)
actual val imageCachePath: String get() = methods.value.platformActions.imageCacheDir.absolutePath + "/"
actual fun imageCacheDir(): File = File(fileManager.fromPath(imageCachePath))
// fun call in order to always access Updated Value // fun call in order to always access Updated Value
actual fun defaultDir(): String = (settings.getStringOrNull(DirKey) ?: defaultBaseDir) + actual fun defaultDir(): File = File(fileManager.newBaseDirectoryFile<SpotiFlyerBaseDir>())
File.separator + "SpotiFlyer" + File.separator
actual fun isPresent(path: String): Boolean = File(path).exists() actual fun isPresent(file: File): Boolean = file.documentFile?.let { fileManager.exists(it) } ?: false
actual fun createDirectory(dirPath: String) { actual fun createDirectory(dirPath: File , subDirectory:String?) {
if(dirPath.documentFile != null) {
if (subDirectory != null) {
fileManager.createDir(dirPath.documentFile!!,subDirectory)
}else {
fileManager.create(dirPath.documentFile!!)
}
}
/*try {
val yourAppDir = File(dirPath) val yourAppDir = File(dirPath)
if (!yourAppDir.exists() && !yourAppDir.isDirectory) { // create empty directory if (!yourAppDir.exists() && !yourAppDir.isDirectory) { // create empty directory
@ -72,40 +156,112 @@ actual class Dir actual constructor(
} else { } else {
logger.i { "$dirPath already exists" } logger.i { "$dirPath already exists" }
} }
} catch (e: SecurityException) {
//TRY USING SAF
Log.d("Directory","USING SAF to create $dirPath")
val file = DocumentFile.fromTreeUri(context, Uri.parse(defaultDir()))
DocumentFile.fromFile()
}*/
} }
actual suspend fun clearCache(): Unit = withContext(dispatcherIO) { actual suspend fun clearCache(): Unit = withContext(dispatcherIO) {
File(imageCacheDir()).deleteRecursively() try {
java.io.File(imageCachePath).deleteRecursively()
}catch (e:Exception){
e.printStackTrace()
}
} }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
actual suspend fun saveFileWithMetadata( actual suspend fun saveFileWithMetadata(
mp3ByteArray: ByteArray, mp3ByteArray: ByteArray,
trackDetails: TrackDetails, trackDetails: TrackDetails,
postProcess:(track: TrackDetails)->Unit postProcess: (track: TrackDetails) -> Unit,
) = withContext(dispatcherIO) { ): Unit = withContext(dispatcherIO) {
val songFile = File(trackDetails.outputFilePath) val songFile = java.io.File(imageCachePath+"Tracks/"+ removeIllegalChars(trackDetails.title) + ".mp3")
try { try {
/*
* Check , if Fetch was Used, File is saved Already, else write byteArray we Received
* */
if(!songFile.exists()) {
/*Make intermediate Dirs if they don't exist yet*/ /*Make intermediate Dirs if they don't exist yet*/
songFile.parentFile.mkdirs() if(!songFile.exists()) {
songFile.parentFile?.mkdirs()
} }
if(mp3ByteArray.isNotEmpty()) songFile.writeBytes(mp3ByteArray) if(mp3ByteArray.isNotEmpty()) songFile.writeBytes(mp3ByteArray)
when (trackDetails.outputFilePath.substringAfterLast('.')) { Mp3File(songFile)
".mp3" -> {
Mp3File(File(songFile.absolutePath))
.removeAllTags() .removeAllTags()
.setId3v1Tags(trackDetails) .setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails) .setId3v2TagsAndSaveFile(trackDetails,songFile.absolutePath)
addToLibrary(songFile.absolutePath)
// Copy File to Desired Location
val documentFile = when(getDirType()){
BaseDirectory.ActiveBaseDirType.SafBaseDir -> {
fileManager.fromUri(Uri.parse(trackDetails.outputFilePath))
}
BaseDirectory.ActiveBaseDirType.JavaFileBaseDir -> {
fileManager.fromPath(trackDetails.outputFilePath)
}
}.also { fileManager.create(it!!) }
try {
fileManager.copyFileContents(
fileManager.fromRawFile(songFile),
documentFile!!
)
songFile.deleteOnExit()
/*val inStream = FileInputStream(songFile)
val buffer = ByteArray(1024)
var readLen: Int
while (inStream.read(buffer).also { readLen = it } != -1) {
outStream?.write(buffer, 0, readLen)
}
inStream.close()
// write the output file (You have now copied the file)
outStream?.flush()
outStream?.close()*/
}catch (e:Exception) {
e.printStackTrace()
}
documentFile?.let {
addToLibrary(File(it))
}
/*when (trackDetails.outputFilePath.substringAfterLast('.')) {
".mp3" -> {
Mp3File(songFile)
.removeAllTags()
.setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails,songFile.absolutePath)
// Copy File to DocumentUri
val documentFile = DocumentFile.fromSingleUri(context,Uri.parse(trackDetails.outputFilePath))
try {
val outStream = context.contentResolver.openOutputStream(documentFile?.uri!!)
val inStream = FileInputStream(songFile)
val buffer = ByteArray(1024)
var readLen: Int
while (inStream.read(buffer).also { readLen = it } != -1) {
outStream?.write(buffer, 0, readLen)
}
inStream.close()
// write the output file (You have now copied the file)
outStream?.flush()
outStream?.close()
}catch (e:Exception) {
e.printStackTrace()
}
documentFile?.let {
addToLibrary(File(it))
}
} }
".m4a" -> { ".m4a" -> {
/*FFmpeg.executeAsync( *//*FFmpeg.executeAsync(
"-i ${m4aFile.absolutePath} -y -b:a 160k -acodec libmp3lame -vn ${m4aFile.absolutePath.substringBeforeLast('.') + ".mp3"}" "-i ${m4aFile.absolutePath} -y -b:a 160k -acodec libmp3lame -vn ${m4aFile.absolutePath.substringBeforeLast('.') + ".mp3"}"
){ _, returnCode -> ){ _, returnCode ->
when (returnCode) { when (returnCode) {
@ -127,18 +283,12 @@ actual class Dir actual constructor(
logger.d { "Async command execution failed with rc=$returnCode" } logger.d { "Async command execution failed with rc=$returnCode" }
} }
} }
}*/ }*//*
} }
else -> { else -> {
try { // TODO
Mp3File(File(songFile.absolutePath))
.removeAllTags()
.setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails)
addToLibrary(songFile.absolutePath)
} catch (e: Exception) { e.printStackTrace() }
}
} }
}*/
}catch (e:Exception){ }catch (e:Exception){
withContext(Dispatchers.Main){ withContext(Dispatchers.Main){
//Toast.makeText(appContext,"Could Not Create File:\n${songFile.absolutePath}",Toast.LENGTH_SHORT).show() //Toast.makeText(appContext,"Could Not Create File:\n${songFile.absolutePath}",Toast.LENGTH_SHORT).show()
@ -148,10 +298,12 @@ actual class Dir actual constructor(
} }
} }
actual fun addToLibrary(path: String) = methods.value.platformActions.addToLibrary(path) actual fun addToLibrary(file: File) {
// methods.value.platformActions.addToLibrary(path)
}
actual suspend fun loadImage(url: String): Picture = withContext(dispatcherIO){ actual suspend fun loadImage(url: String): Picture = withContext(dispatcherIO){
val cachePath = imageCacheDir() + getNameURL(url) val cachePath = imageCachePath + getNameURL(url)
Picture(image = (loadCachedImage(cachePath) ?: freshImage(url))?.asImageBitmap()) Picture(image = (loadCachedImage(cachePath) ?: freshImage(url))?.asImageBitmap())
} }
@ -169,6 +321,7 @@ actual class Dir actual constructor(
actual suspend fun cacheImage(image: Any, path: String):Unit = withContext(dispatcherIO) { actual suspend fun cacheImage(image: Any, path: String):Unit = withContext(dispatcherIO) {
try { try {
java.io.File(path).parentFile?.mkdirs()
FileOutputStream(path).use { out -> FileOutputStream(path).use { out ->
(image as? Bitmap)?.compress(Bitmap.CompressFormat.JPEG, 100, out) (image as? Bitmap)?.compress(Bitmap.CompressFormat.JPEG, 100, out)
} }
@ -189,7 +342,7 @@ actual class Dir actual constructor(
if (result != null) { if (result != null) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
cacheImage(result, imageCacheDir() + getNameURL(url)) cacheImage(result, imageCachePath + getNameURL(url))
} }
result result
} else null } else null
@ -200,4 +353,78 @@ actual class Dir actual constructor(
} }
actual val db: Database? = spotiFlyerDatabase.instance actual val db: Database? = spotiFlyerDatabase.instance
actual fun finalOutputPath(
itemName: String,
type: String,
subFolder: String,
extension: String,
):String = finalOutputFile(
itemName,
type,
subFolder,
extension,
).documentFile?.getFullPath() ?: throw(Exception("no path for $itemName"))
actual fun finalOutputFile(
itemName: String,
type: String,
subFolder: String,
extension: String,
):File {
val file = fileManager.create(
defaultDir().documentFile!!, //Base Dir
DirectorySegment(removeIllegalChars(type)),
DirectorySegment(removeIllegalChars(subFolder)),
)
return File(file?.clone(FileSegment(removeIllegalChars(itemName) + extension)))/*.also {
if(fileManager.getLength(it.documentFile!!) == 0L){
fileManager.delete(it.documentFile!!)
}
}*/
/*GlobalScope.launch {
// Create Intermediate Directories
var file = defaultDir().documentFile
file = file.findFile(removeIllegalChars(type))
?: file.createDirectory(removeIllegalChars(type))
?: throw Exception("Couldn't Find/Create $type Directory")
if (subFolder.isNotEmpty()) file.findFile(removeIllegalChars(subFolder))
?: file.createDirectory(removeIllegalChars(subFolder))
?: throw Exception("Couldn't Find/Create $subFolder Directory")
}
val sep = "%2F"
val finalUri = defaultDir().documentFile.uri.toString() + sep +
removeIllegalChars(type) + sep +
removeIllegalChars(subFolder) + sep +
removeIllegalChars(itemName) + extension
return File(
DocumentFile.fromSingleUri(context,Uri.parse(finalUri))!!
).also {
Log.d("Final Output File",it.documentFile.uri.toString())
}*/
/*file = file?.findFile(removeIllegalChars(type))
?: file?.createDirectory(removeIllegalChars(type))
?: throw Exception("Couldn't Find/Create $type Directory")
if (subFolder.isNotEmpty()) file = file.findFile(removeIllegalChars(subFolder))
?: file.createDirectory(removeIllegalChars(subFolder))
?: throw Exception("Couldn't Find/Create $subFolder Directory")
// TODO check Mime
file = file.findFile(removeIllegalChars(itemName))
?: file.createFile("audio/mpeg",removeIllegalChars(itemName))
?: throw Exception("Couldn't Find/Create ${removeIllegalChars(itemName) + extension} File")
Log.d("Final Output File",file.uri.toString())
return File(file).also {
val size = it.documentFile.length()
Log.d("File size", size.toString())
if(size == 0L) it.documentFile.delete()
}*/
}
} }

View File

@ -99,9 +99,8 @@ fun Mp3File.setId3v1Tags(track: TrackDetails): Mp3File {
} }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) { suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails,tempMp3Path:String) {
val id3v2Tag = ID3v24Tag().apply { val id3v2Tag = ID3v24Tag().apply {
artist = track.artists.joinToString(", ") artist = track.artists.joinToString(", ")
title = track.title title = track.title
album = track.albumName album = track.albumName
@ -118,9 +117,9 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
fis.close() fis.close()
id3v2Tag.setAlbumImage(bytesArray, "image/jpeg") id3v2Tag.setAlbumImage(bytesArray, "image/jpeg")
this.id3v2Tag = id3v2Tag this.id3v2Tag = id3v2Tag
saveFile(track.outputFilePath) saveFile(tempMp3Path)
} catch (e: java.io.FileNotFoundException) { } catch (e: java.io.FileNotFoundException) {
Log.e("Error", "Couldn't Write Cached Mp3 Album Art, error: ${e.stackTrace}") Log.e("Error", "Couldn't Write Cached Mp3 Album Art, Downloading And Trying Again, error: ${e.message}")
try { try {
// Image Still Not Downloaded! // Image Still Not Downloaded!
// Lets Download Now and Write it into Album Art // Lets Download Now and Write it into Album Art
@ -130,7 +129,7 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
is DownloadResult.Success -> { is DownloadResult.Success -> {
id3v2Tag.setAlbumImage(it.byteArray, "image/jpeg") id3v2Tag.setAlbumImage(it.byteArray, "image/jpeg")
this.id3v2Tag = id3v2Tag this.id3v2Tag = id3v2Tag
saveFile(track.outputFilePath) saveFile(tempMp3Path)
} }
is DownloadResult.Progress -> {} // Nothing for Now , no progress bar to show is DownloadResult.Progress -> {} // Nothing for Now , no progress bar to show
} }
@ -143,6 +142,7 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
} }
fun Mp3File.saveFile(filePath: String) { fun Mp3File.saveFile(filePath: String) {
Log.d("Mp3 File Save",filePath)
save(filePath.substringBeforeLast('.') + ".new.mp3") save(filePath.substringBeforeLast('.') + ".new.mp3")
val m4aFile = File(filePath) val m4aFile = File(filePath)
m4aFile.delete() m4aFile.delete()

View File

@ -38,7 +38,10 @@ import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.github.k1rakishou.fsaf.FileManager
import com.github.k1rakishou.fsaf.file.AbstractFile
import com.shabinder.common.di.* import com.shabinder.common.di.*
import com.shabinder.common.di.providers.getData
import com.shabinder.common.di.utils.ParallelExecutor import com.shabinder.common.di.utils.ParallelExecutor
import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.DownloadResult
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
@ -47,6 +50,7 @@ import com.shabinder.downloader.models.formats.Format
import com.shabinder.common.models.Status import com.shabinder.common.models.Status
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
@ -337,34 +341,14 @@ class ForegroundService : Service(), CoroutineScope {
service.createNotificationChannel(channel) service.createNotificationChannel(channel)
} }
/**
* Cleaning All Residual Files except Mp3 Files
**/
private fun cleanFiles(dir: File) {
logger.d(tag) { "Starting Cleaning in ${dir.path} " }
val fList = dir.listFiles()
fList?.let {
for (file in fList) {
if (file.isDirectory) {
cleanFiles(file)
} else if (file.isFile) {
if (file.path.toString().substringAfterLast(".") != "mp3") {
logger.d(tag) { "Cleaning ${file.path}" }
file.delete()
}
}
}
}
}
private fun killService() { private fun killService() {
launch { launch {
logger.d(tag) { "Killing Self" } logger.d(tag) { "Killing Self" }
messageList = mutableListOf("Cleaning And Exiting", "", "", "", "") messageList = mutableListOf("Cleaning And Exiting", "", "", "", "")
downloadService.close() downloadService.close()
updateNotification() updateNotification()
cleanFiles(File(dir.defaultDir())) // dir.defaultDir().documentFile?.let { cleanFiles(it,dir.fileManager,logger) }
// TODO cleanFiles(File(dir.imageCacheDir())) cleanFiles(File(dir.imageCachePath + "Tracks/"),logger)
messageList = mutableListOf("", "", "", "", "") messageList = mutableListOf("", "", "", "", "")
releaseWakeLock() releaseWakeLock()
serviceJob.cancel() serviceJob.cancel()

View File

@ -0,0 +1,47 @@
package com.shabinder.common.di.worker
import co.touchlab.kermit.Kermit
import com.github.k1rakishou.fsaf.FileManager
import com.github.k1rakishou.fsaf.file.AbstractFile
import java.io.File
/**
* Cleaning All Residual Files except Mp3 Files
**/
fun cleanFiles(dir: File,logger: Kermit) {
try {
logger.d("File Cleaning") { "Starting Cleaning in ${dir.path} " }
val fList = dir.listFiles()
fList?.let {
for (file in fList) {
if (file.isDirectory) {
cleanFiles(file, logger)
} else if (file.isFile) {
if (file.path.toString().substringAfterLast(".") != "mp3") {
logger.d("Files Cleaning") { "Cleaning ${file.path}" }
file.delete()
}
}
}
}
} catch (e:Exception) { e.printStackTrace() }
}
/**
* Cleaning All Residual Files except Mp3 Files
**/
fun cleanFiles(directory: AbstractFile,fm: FileManager,logger: Kermit) {
try {
logger.d("Files Cleaning") { "Starting Cleaning in ${directory.getFullPath()} " }
val fList = fm.listFiles(directory)
for (file in fList) {
if (fm.isDirectory(file)) {
cleanFiles(file, fm, logger)
} else if (fm.isFile(file)) {
if (file.getFullPath().substringAfterLast(".") != "mp3") {
logger.d("Files Cleaning") { "Cleaning ${file.getFullPath()}" }
fm.delete(file)
}
}
}
} catch (e:Exception) { e.printStackTrace() }
}

View File

@ -17,7 +17,6 @@
package com.shabinder.common.di package com.shabinder.common.di
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import co.touchlab.stately.ensureNeverFrozen
import com.russhwolf.settings.Settings import com.russhwolf.settings.Settings
import com.shabinder.common.database.databaseModule import com.shabinder.common.database.databaseModule
import com.shabinder.common.database.getLogger import com.shabinder.common.database.getLogger
@ -25,7 +24,7 @@ import com.shabinder.common.di.providers.GaanaProvider
import com.shabinder.common.di.providers.SpotifyProvider import com.shabinder.common.di.providers.SpotifyProvider
import com.shabinder.common.di.providers.YoutubeMp3 import com.shabinder.common.di.providers.YoutubeMp3
import com.shabinder.common.di.providers.YoutubeMusic import com.shabinder.common.di.providers.YoutubeMusic
import com.shabinder.common.models.NativeAtomicReference import com.shabinder.common.di.providers.YoutubeProvider
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.features.HttpTimeout import io.ktor.client.features.HttpTimeout
import io.ktor.client.features.json.JsonFeature import io.ktor.client.features.json.JsonFeature
@ -87,12 +86,6 @@ fun createHttpClient(enableNetworkLogs: Boolean = false) = HttpClient {
} }
) )
}*/ }*/
// Timeout
install(HttpTimeout) {
// requestTimeoutMillis = 20000L
connectTimeoutMillis = 15000L
socketTimeoutMillis = 15000L
}
if (enableNetworkLogs) { if (enableNetworkLogs) {
install(Logging) { install(Logging) {
logger = Logger.DEFAULT logger = Logger.DEFAULT

View File

@ -18,10 +18,10 @@ package com.shabinder.common.di
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.russhwolf.settings.Settings import com.russhwolf.settings.Settings
import com.russhwolf.settings.SettingsListener
import com.shabinder.common.database.SpotiFlyerDatabase import com.shabinder.common.database.SpotiFlyerDatabase
import com.shabinder.common.di.utils.removeIllegalChars import com.shabinder.common.di.utils.removeIllegalChars
import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.DownloadResult
import com.shabinder.common.models.File
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.database.Database import com.shabinder.database.Database
import io.ktor.client.request.* import io.ktor.client.request.*
@ -38,17 +38,27 @@ expect class Dir (
spotiFlyerDatabase: SpotiFlyerDatabase, spotiFlyerDatabase: SpotiFlyerDatabase,
) { ) {
val db: Database? val db: Database?
fun isPresent(path: String): Boolean val isAnalyticsEnabled:Boolean
fun fileSeparator(): String fun enableAnalytics()
fun defaultDir(): String fun isPresent(file: File): Boolean
fun imageCacheDir(): String //fun fileSeparator(): String
fun createDirectory(dirPath: String) fun defaultDir(): File
fun setDownloadDirectory(newBasePath:String) fun imageCacheDir(): File
val imageCachePath: String
/*
* on Android dirPath represents Directory Name
* */
fun createDirectory(dirPath: File , subDirectory:String? = null)
fun setDownloadDirectory(newBasePath:File)
suspend fun cacheImage(image: Any, path: String) // in Android = ImageBitmap, Desktop = BufferedImage suspend fun cacheImage(image: Any, path: String) // in Android = ImageBitmap, Desktop = BufferedImage
suspend fun loadImage(url: String): Picture suspend fun loadImage(url: String): Picture
suspend fun clearCache() suspend fun clearCache()
suspend fun saveFileWithMetadata(mp3ByteArray: ByteArray, trackDetails: TrackDetails,postProcess:(track: TrackDetails)->Unit = {}) suspend fun saveFileWithMetadata(mp3ByteArray: ByteArray, trackDetails: TrackDetails,postProcess:(track: TrackDetails)->Unit = {})
fun addToLibrary(path: String) fun addToLibrary(file: File)
fun finalOutputFile(itemName: String, type: String, subFolder: String, extension: String = ".mp3"): File
fun finalOutputPath(itemName: String, type: String, subFolder: String, extension: String = ".mp3"): String
} }
suspend fun downloadFile(url: String): Flow<DownloadResult> { suspend fun downloadFile(url: String): Flow<DownloadResult> {
@ -92,8 +102,9 @@ suspend fun downloadByteArray(
return response return response
} }
// Function to get cache Image Name
fun getNameURL(url: String): String { fun getNameURL(url: String): String {
return url.substring(url.lastIndexOf('/', url.lastIndexOf('/') - 1) + 1, url.length).replace('/', '_') return url.substring(url.lastIndexOf('/', url.lastIndexOf('/') - 1) + 1, url.length).replace('/', '_') + ".jpeg"
} }
/* /*
@ -102,13 +113,13 @@ fun getNameURL(url: String): String {
fun Dir.createDirectories() { fun Dir.createDirectories() {
createDirectory(defaultDir()) createDirectory(defaultDir())
createDirectory(imageCacheDir()) createDirectory(imageCacheDir())
createDirectory(defaultDir() + "Tracks/") createDirectory(defaultDir(),"Tracks/")
createDirectory(defaultDir() + "Albums/") createDirectory(defaultDir(),"Albums/")
createDirectory(defaultDir() + "Playlists/") createDirectory(defaultDir(),"Playlists/")
createDirectory(defaultDir() + "YT_Downloads/") createDirectory(defaultDir(),"YT_Downloads/")
} }
fun Dir.finalOutputDir(itemName: String, type: String, subFolder: String, defaultDir: String, extension: String = ".mp3"): String = /*fun Dir.finalOutputDir(itemName: String, type: String, subFolder: String, defaultDir: String, extension: String = ".mp3"): String =
defaultDir + removeIllegalChars(type) + this.fileSeparator() + defaultDir + removeIllegalChars(type) + this.fileSeparator() +
if (subFolder.isEmpty())"" else { removeIllegalChars(subFolder) + this.fileSeparator() } + if (subFolder.isEmpty())"" else { removeIllegalChars(subFolder) + this.fileSeparator() } +
removeIllegalChars(itemName) + extension removeIllegalChars(itemName) + extension*/

View File

@ -21,13 +21,10 @@ import com.shabinder.common.di.providers.GaanaProvider
import com.shabinder.common.di.providers.SpotifyProvider import com.shabinder.common.di.providers.SpotifyProvider
import com.shabinder.common.di.providers.YoutubeMp3 import com.shabinder.common.di.providers.YoutubeMp3
import com.shabinder.common.di.providers.YoutubeMusic import com.shabinder.common.di.providers.YoutubeMusic
import com.shabinder.common.di.providers.YoutubeProvider
import com.shabinder.common.models.PlatformQueryResult import com.shabinder.common.models.PlatformQueryResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class FetchPlatformQueryResult( class FetchPlatformQueryResult(
val gaanaProvider: GaanaProvider, val gaanaProvider: GaanaProvider,

View File

@ -18,8 +18,8 @@ package com.shabinder.common.di.providers
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.shabinder.common.di.Dir import com.shabinder.common.di.Dir
import com.shabinder.common.di.finalOutputDir
import com.shabinder.common.di.gaana.GaanaRequests import com.shabinder.common.di.gaana.GaanaRequests
import com.shabinder.common.di.getNameURL
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.PlatformQueryResult import com.shabinder.common.models.PlatformQueryResult
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
@ -136,7 +136,7 @@ class GaanaProvider(
title = it.track_title, title = it.track_title,
artists = it.artist.map { artist -> artist?.name.toString() }, artists = it.artist.map { artist -> artist?.name.toString() },
durationSec = it.duration, durationSec = it.duration,
albumArtPath = dir.imageCacheDir() + (it.artworkLink.substringBeforeLast('/').substringAfterLast('/')) + ".jpeg", albumArtPath = dir.imageCachePath + getNameURL(it.artworkLink),
albumName = it.album_title, albumName = it.album_title,
year = it.release_date, year = it.release_date,
comment = "Genres:${it.genre?.map { genre -> genre?.name }?.reduceOrNull { acc, s -> acc + s }}", comment = "Genres:${it.genre?.map { genre -> genre?.name }?.reduceOrNull { acc, s -> acc + s }}",
@ -144,16 +144,15 @@ class GaanaProvider(
downloaded = it.downloaded ?: DownloadStatus.NotDownloaded, downloaded = it.downloaded ?: DownloadStatus.NotDownloaded,
source = Source.Gaana, source = Source.Gaana,
albumArtURL = it.artworkLink.replace("http:","https:"), albumArtURL = it.artworkLink.replace("http:","https:"),
outputFilePath = dir.finalOutputDir(it.track_title, type, subFolder, dir.defaultDir()/*,".m4a"*/) outputFilePath = dir.finalOutputPath(it.track_title, type, subFolder /*,".m4a"*/)
) )
} }
private fun GaanaTrack.updateStatusIfPresent(folderType: String, subFolder: String) { private fun GaanaTrack.updateStatusIfPresent(folderType: String, subFolder: String) {
if (dir.isPresent( if (dir.isPresent(
dir.finalOutputDir( dir.finalOutputFile(
track_title, track_title,
folderType, folderType,
subFolder, subFolder,
dir.defaultDir()
) )
) )
) { // Download Already Present!! ) { // Download Already Present!!

View File

@ -17,14 +17,10 @@
package com.shabinder.common.di.providers package com.shabinder.common.di.providers
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import co.touchlab.stately.ensureNeverFrozen
import co.touchlab.stately.freeze
import com.shabinder.common.di.Dir import com.shabinder.common.di.Dir
import com.shabinder.common.di.TokenStore import com.shabinder.common.di.TokenStore
import com.shabinder.common.di.createHttpClient import com.shabinder.common.di.createHttpClient
import com.shabinder.common.di.finalOutputDir import com.shabinder.common.di.getNameURL
import com.shabinder.common.di.kotlinxSerializer
import com.shabinder.common.di.ktorHttpClient
import com.shabinder.common.di.spotify.SpotifyRequests import com.shabinder.common.di.spotify.SpotifyRequests
import com.shabinder.common.di.spotify.authenticateSpotify import com.shabinder.common.di.spotify.authenticateSpotify
import com.shabinder.common.models.NativeAtomicReference import com.shabinder.common.models.NativeAtomicReference
@ -35,12 +31,8 @@ import com.shabinder.common.models.spotify.Image
import com.shabinder.common.models.spotify.Source import com.shabinder.common.models.spotify.Source
import com.shabinder.common.models.spotify.Track import com.shabinder.common.models.spotify.Track
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.features.auth.*
import io.ktor.client.features.auth.providers.*
import io.ktor.client.features.defaultRequest import io.ktor.client.features.defaultRequest
import io.ktor.client.features.json.JsonFeature
import io.ktor.client.request.header import io.ktor.client.request.header
import kotlin.native.concurrent.SharedImmutable
class SpotifyProvider( class SpotifyProvider(
private val tokenStore: TokenStore, private val tokenStore: TokenStore,
@ -224,28 +216,28 @@ class SpotifyProvider(
} }
private fun List<Track>.toTrackDetailsList(type: String, subFolder: String) = this.map { private fun List<Track>.toTrackDetailsList(type: String, subFolder: String) = this.map {
val albumArtLink = it.album?.images?.firstOrNull()?.url.toString()
TrackDetails( TrackDetails(
title = it.name.toString(), title = it.name.toString(),
artists = it.artists?.map { artist -> artist?.name.toString() } ?: listOf(), artists = it.artists?.map { artist -> artist?.name.toString() } ?: listOf(),
durationSec = (it.duration_ms / 1000).toInt(), durationSec = (it.duration_ms / 1000).toInt(),
albumArtPath = dir.imageCacheDir() + (it.album?.images?.firstOrNull()?.url.toString()).substringAfterLast('/') + ".jpeg", albumArtPath = dir.imageCachePath + getNameURL(albumArtLink),
albumName = it.album?.name, albumName = it.album?.name,
year = it.album?.release_date, year = it.album?.release_date,
comment = "Genres:${it.album?.genres?.joinToString()}", comment = "Genres:${it.album?.genres?.joinToString()}",
trackUrl = it.href, trackUrl = it.href,
downloaded = it.downloaded, downloaded = it.downloaded,
source = Source.Spotify, source = Source.Spotify,
albumArtURL = it.album?.images?.firstOrNull()?.url.toString(), albumArtURL = albumArtLink,
outputFilePath = dir.finalOutputDir(it.name.toString(), type, subFolder, dir.defaultDir()/*,".m4a"*/) outputFilePath = dir.finalOutputPath(it.name.toString(), type, subFolder/*,".m4a"*/)
) )
} }
private fun Track.updateStatusIfPresent(folderType: String, subFolder: String) { private fun Track.updateStatusIfPresent(folderType: String, subFolder: String) {
if (dir.isPresent( if (dir.isPresent(
dir.finalOutputDir( dir.finalOutputFile(
name.toString(), name.toString(),
folderType, folderType,
subFolder, subFolder,
dir.defaultDir()
) )
) )
) { // Download Already Present!! ) { // Download Already Present!!

View File

@ -14,9 +14,11 @@
* * along with this program. If not, see <https://www.gnu.org/licenses/>. * * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package com.shabinder.common.di package com.shabinder.common.di.providers
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.shabinder.common.di.Dir
import com.shabinder.common.di.getNameURL
import com.shabinder.common.di.utils.removeIllegalChars import com.shabinder.common.di.utils.removeIllegalChars
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.PlatformQueryResult import com.shabinder.common.models.PlatformQueryResult
@ -106,15 +108,14 @@ class YoutubeProvider(
title = it.title ?: "N/A", title = it.title ?: "N/A",
artists = listOf(it.author ?: "N/A"), artists = listOf(it.author ?: "N/A"),
durationSec = it.lengthSeconds, durationSec = it.lengthSeconds,
albumArtPath = dir.imageCacheDir() + it.videoId + ".jpeg", albumArtPath = dir.imageCachePath + getNameURL(coverUrl),
source = Source.YouTube, source = Source.YouTube,
albumArtURL = "https://i.ytimg.com/vi/${it.videoId}/hqdefault.jpg", albumArtURL = coverUrl,
downloaded = if (dir.isPresent( downloaded = if (dir.isPresent(
dir.finalOutputDir( dir.finalOutputFile(
itemName = it.title ?: "N/A", itemName = it.title ?: "N/A",
type = folderType, type = folderType,
subFolder = subFolder, subFolder = subFolder,
dir.defaultDir()
) )
) )
) )
@ -122,7 +123,7 @@ class YoutubeProvider(
else { else {
DownloadStatus.NotDownloaded DownloadStatus.NotDownloaded
}, },
outputFilePath = dir.finalOutputDir(it.title ?: "N/A", folderType, subFolder, dir.defaultDir()/*,".m4a"*/), outputFilePath = dir.finalOutputPath(it.title ?: "N/A", folderType, subFolder/*,".m4a"*/),
videoID = it.videoId videoID = it.videoId
) )
} }
@ -160,15 +161,14 @@ class YoutubeProvider(
title = name, title = name,
artists = listOf(detail.author ?: "N/A"), artists = listOf(detail.author ?: "N/A"),
durationSec = detail.lengthSeconds, durationSec = detail.lengthSeconds,
albumArtPath = dir.imageCacheDir() + "$searchId.jpeg", albumArtPath = dir.imageCachePath + getNameURL(coverUrl),
source = Source.YouTube, source = Source.YouTube,
albumArtURL = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg", albumArtURL = coverUrl,
downloaded = if (dir.isPresent( downloaded = if (dir.isPresent(
dir.finalOutputDir( dir.finalOutputFile(
itemName = name, itemName = name,
type = folderType, type = folderType,
subFolder = subFolder, subFolder = subFolder,
defaultDir = dir.defaultDir()
) )
) )
) )
@ -176,7 +176,7 @@ class YoutubeProvider(
else { else {
DownloadStatus.NotDownloaded DownloadStatus.NotDownloaded
}, },
outputFilePath = dir.finalOutputDir(name, folderType, subFolder, dir.defaultDir()/*,".m4a"*/), outputFilePath = dir.finalOutputPath(name, folderType, subFolder /*,".m4a"*/),
videoID = searchId videoID = searchId
) )
) )

View File

@ -90,6 +90,7 @@ fun removeIllegalChars(fileName: String): String {
name = fileName.replace(c, '_') name = fileName.replace(c, '_')
} }
name = name.replace("\\s".toRegex(), "_") name = name.replace("\\s".toRegex(), "_")
name = name.replace("/".toRegex(), "_")
name = name.replace("\\)".toRegex(), "") name = name.replace("\\)".toRegex(), "")
name = name.replace("\\(".toRegex(), "") name = name.replace("\\(".toRegex(), "")
name = name.replace("\\[".toRegex(), "") name = name.replace("\\[".toRegex(), "")

View File

@ -17,6 +17,7 @@
package com.shabinder.common.di package com.shabinder.common.di
import com.shabinder.common.di.providers.YoutubeMp3 import com.shabinder.common.di.providers.YoutubeMp3
import com.shabinder.common.di.providers.getData
import com.shabinder.common.di.utils.ParallelExecutor import com.shabinder.common.di.utils.ParallelExecutor
import com.shabinder.common.models.AllPlatforms import com.shabinder.common.models.AllPlatforms
import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.DownloadResult

View File

@ -45,6 +45,13 @@ actual class Dir actual constructor(
) { ) {
companion object { companion object {
const val DirKey = "downloadDir" const val DirKey = "downloadDir"
const val AnalyticsKey = "analytics"
}
actual val isAnalyticsEnabled get() = settings.getBooleanOrNull(AnalyticsKey) ?: false
actual fun enableAnalytics() {
settings.putBoolean(AnalyticsKey,true)
} }
init { init {

View File

@ -1,5 +1,6 @@
package com.shabinder.common.di package com.shabinder.common.di
import com.shabinder.common.di.providers.getData
import com.shabinder.common.di.utils.ParallelExecutor import com.shabinder.common.di.utils.ParallelExecutor
import com.shabinder.common.models.AllPlatforms import com.shabinder.common.models.AllPlatforms
import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.DownloadResult

View File

@ -21,7 +21,6 @@ import platform.Foundation.sendSynchronousRequest
import platform.Foundation.writeToFile import platform.Foundation.writeToFile
import platform.UIKit.UIImage import platform.UIKit.UIImage
import platform.UIKit.UIImageJPEGRepresentation import platform.UIKit.UIImageJPEGRepresentation
import java.lang.System
actual class Dir actual constructor( actual class Dir actual constructor(
val logger: Kermit, val logger: Kermit,
@ -30,6 +29,13 @@ actual class Dir actual constructor(
) { ) {
companion object { companion object {
const val DirKey = "downloadDir" const val DirKey = "downloadDir"
const val AnalyticsKey = "analytics"
}
actual val isAnalyticsEnabled get() = settings.getBooleanOrNull(AnalyticsKey) ?: false
actual fun enableAnalytics() {
settings.putBoolean(AnalyticsKey,true)
} }
actual fun isPresent(path: String): Boolean = NSFileManager.defaultManager.fileExistsAtPath(path) actual fun isPresent(path: String): Boolean = NSFileManager.defaultManager.fileExistsAtPath(path)

View File

@ -39,8 +39,17 @@ actual class Dir actual constructor(
) { ) {
companion object { companion object {
const val DirKey = "downloadDir" const val DirKey = "downloadDir"
const val AnalyticsKey = "analytics"
} }
actual val isAnalyticsEnabled get() = settings.getBooleanOrNull(AnalyticsKey) ?: false
actual fun enableAnalytics() {
settings.putBoolean(AnalyticsKey,true)
}
actual fun setDownloadDirectory(newBasePath:String) = settings.putString(DirKey,newBasePath)
/*init { /*init {
createDirectories() createDirectories()
}*/ }*/

View File

@ -65,6 +65,11 @@ interface SpotiFlyerList {
val link: String val link: String
val listOutput: Consumer<Output> val listOutput: Consumer<Output>
val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>> val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
val listAnalytics: Analytics
}
interface Analytics {
} }
sealed class Output { sealed class Output {

View File

@ -25,12 +25,13 @@ import com.shabinder.common.main.integration.SpotiFlyerMainImpl
import com.shabinder.common.models.Consumer import com.shabinder.common.models.Consumer
import com.shabinder.common.models.DownloadRecord import com.shabinder.common.models.DownloadRecord
import com.shabinder.database.Database import com.shabinder.database.Database
import kotlinx.coroutines.flow.Flow
interface SpotiFlyerMain { interface SpotiFlyerMain {
val models: Value<State> val models: Value<State>
val analytics: Analytics
/* /*
* We Intend to Move to List Screen * We Intend to Move to List Screen
* Note: Implementation in Root * Note: Implementation in Root
@ -57,6 +58,11 @@ interface SpotiFlyerMain {
val storeFactory: StoreFactory val storeFactory: StoreFactory
val database: Database? val database: Database?
val dir: Dir val dir: Dir
val mainAnalytics: Analytics
}
interface Analytics {
fun donationDialogVisit()
} }
sealed class Output { sealed class Output {

View File

@ -18,7 +18,6 @@ package com.shabinder.common.main.integration
import co.touchlab.stately.ensureNeverFrozen import co.touchlab.stately.ensureNeverFrozen
import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.lifecycle.doOnDestroy
import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.Value
import com.shabinder.common.di.Picture import com.shabinder.common.di.Picture
import com.shabinder.common.di.utils.asValue import com.shabinder.common.di.utils.asValue
@ -40,9 +39,6 @@ internal class SpotiFlyerMainImpl(
init { init {
instanceKeeper.ensureNeverFrozen() instanceKeeper.ensureNeverFrozen()
lifecycle.doOnDestroy {
cache.invalidateAll()
}
} }
private val store = private val store =
@ -55,11 +51,13 @@ internal class SpotiFlyerMainImpl(
private val cache = Cache.Builder private val cache = Cache.Builder
.newBuilder() .newBuilder()
.maximumCacheSize(20) .maximumCacheSize(25)
.build<String, Picture>() .build<String, Picture>()
override val models: Value<State> = store.asValue() override val models: Value<State> = store.asValue()
override val analytics = mainAnalytics
override fun onLinkSearch(link: String) { override fun onLinkSearch(link: String) {
if (methods.value.isInternetAvailable) mainOutput.callback(Output.Search(link = link)) if (methods.value.isInternetAvailable) mainOutput.callback(Output.Search(link = link))
else methods.value.showPopUpMessage("Check Network Connection Please") else methods.value.showPopUpMessage("Check Network Connection Please")

View File

@ -54,6 +54,14 @@ interface SpotiFlyerRoot {
val directories: Dir val directories: Dir
val downloadProgressReport: MutableSharedFlow<HashMap<String, DownloadStatus>> val downloadProgressReport: MutableSharedFlow<HashMap<String, DownloadStatus>>
val actions:Actions val actions:Actions
val analytics: Analytics
}
interface Analytics {
fun appLaunchEvent()
fun homeScreenVisit()
fun listScreenVisit()
fun donationDialogVisit()
} }
} }

View File

@ -37,6 +37,7 @@ import com.shabinder.common.models.AllPlatforms
import com.shabinder.common.models.Consumer import com.shabinder.common.models.Consumer
import com.shabinder.common.models.methods import com.shabinder.common.models.methods
import com.shabinder.common.root.SpotiFlyerRoot import com.shabinder.common.root.SpotiFlyerRoot
import com.shabinder.common.root.SpotiFlyerRoot.Analytics
import com.shabinder.common.root.SpotiFlyerRoot.Child import com.shabinder.common.root.SpotiFlyerRoot.Child
import com.shabinder.common.root.SpotiFlyerRoot.Dependencies import com.shabinder.common.root.SpotiFlyerRoot.Dependencies
import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks
@ -49,7 +50,8 @@ internal class SpotiFlyerRootImpl(
componentContext: ComponentContext, componentContext: ComponentContext,
private val main: (ComponentContext, output:Consumer<SpotiFlyerMain.Output>)->SpotiFlyerMain, private val main: (ComponentContext, output:Consumer<SpotiFlyerMain.Output>)->SpotiFlyerMain,
private val list: (ComponentContext, link:String, output:Consumer<SpotiFlyerList.Output>)->SpotiFlyerList, private val list: (ComponentContext, link:String, output:Consumer<SpotiFlyerList.Output>)->SpotiFlyerList,
private val actions: Actions private val actions: Actions,
private val analytics: Analytics
) : SpotiFlyerRoot, ComponentContext by componentContext { ) : SpotiFlyerRoot, ComponentContext by componentContext {
constructor( constructor(
@ -72,7 +74,8 @@ internal class SpotiFlyerRootImpl(
dependencies dependencies
) )
}, },
actions = dependencies.actions.freeze() actions = dependencies.actions.freeze(),
analytics = dependencies.analytics
) { ) {
instanceKeeper.ensureNeverFrozen() instanceKeeper.ensureNeverFrozen()
methods.value = dependencies.actions.freeze() methods.value = dependencies.actions.freeze()
@ -113,16 +116,23 @@ internal class SpotiFlyerRootImpl(
private fun onMainOutput(output: SpotiFlyerMain.Output) = private fun onMainOutput(output: SpotiFlyerMain.Output) =
when (output) { when (output) {
is SpotiFlyerMain.Output.Search -> router.push(Configuration.List(link = output.link)) is SpotiFlyerMain.Output.Search -> {
router.push(Configuration.List(link = output.link))
analytics.listScreenVisit()
}
} }
private fun onListOutput(output: SpotiFlyerList.Output): Unit = private fun onListOutput(output: SpotiFlyerList.Output): Unit =
when (output) { when (output) {
is SpotiFlyerList.Output.Finished -> router.pop() is SpotiFlyerList.Output.Finished -> {
router.pop()
analytics.homeScreenVisit()
}
} }
private fun authenticateSpotify(spotifyProvider: SpotifyProvider, override:Boolean){ private fun authenticateSpotify(spotifyProvider: SpotifyProvider, override:Boolean){
GlobalScope.launch(Dispatchers.Default) { GlobalScope.launch(Dispatchers.Default) {
analytics.appLaunchEvent()
/*Authenticate Spotify Client*/ /*Authenticate Spotify Client*/
spotifyProvider.authenticateSpotifyClient(override) spotifyProvider.authenticateSpotifyClient(override)
} }
@ -143,6 +153,9 @@ private fun spotiFlyerMain(componentContext: ComponentContext, output: Consumer<
dependencies = object : SpotiFlyerMain.Dependencies, Dependencies by dependencies { dependencies = object : SpotiFlyerMain.Dependencies, Dependencies by dependencies {
override val mainOutput: Consumer<SpotiFlyerMain.Output> = output override val mainOutput: Consumer<SpotiFlyerMain.Output> = output
override val dir: Dir = directories override val dir: Dir = directories
override val mainAnalytics = object : SpotiFlyerMain.Analytics {
override fun donationDialogVisit() = analytics.donationDialogVisit()
}
} }
) )
@ -155,5 +168,6 @@ private fun spotiFlyerList(componentContext: ComponentContext, link: String, out
override val link: String = link override val link: String = link
override val listOutput: Consumer<SpotiFlyerList.Output> = output override val listOutput: Consumer<SpotiFlyerList.Output> = output
override val downloadProgressFlow = downloadProgressReport override val downloadProgressFlow = downloadProgressReport
override val listAnalytics = object : SpotiFlyerList.Analytics {}
} }
) )