Merge pull request #523 from Shabinder/crashlytics&fixes

Major Refactoring and Bug Fixes
This commit is contained in:
Shabinder Singh 2021-09-05 20:20:56 +05:30 committed by GitHub
commit 764876ec77
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
180 changed files with 3732 additions and 1893 deletions

View File

@ -0,0 +1,95 @@
on:
[workflow_dispatch]
jobs:
create-linux-package:
runs-on: ubuntu-latest
name: Create Deb Package
steps:
# Setup Java environment for the next steps
- name: Setup Java
uses: actions/setup-java@v1
with:
java-version: 15
# Check out current repository
- name: Fetch Sources
uses: actions/checkout@v2.3.1
# Build Desktop application
- name: Desktop App
run: ./gradlew :desktop:packageUberJarForCurrentOS
# Create a Draft Release
- name: Draft Release
uses: ncipollo/release-action@v1
with:
draft: true
tag: "3.3.0"
artifacts: "desktop\build\compose\jars\*.jar"
token: ${{ secrets.GH_TOKEN }}
commit: main
# bodyFile: "body.md"
#
# create-macos-package:
# runs-on: macos-latest
## needs: create_staging_repository
# steps:
# - name: Checkout
# uses: actions/checkout@v2
# - name: Configure JDK
# uses: actions/setup-java@v1
# with:
# java-version: 14
# - name: Publish
# run: |
# ./gradlew publishAllPublicationsToMavenRepository -PSONATYPE_REPOSITORY_ID=${{ needs.create_staging_repository.outputs.repository_id }}
# env:
# MANUAL_REPOSITORY: ${{ secrets.MANUAL_REPOSITORY }}
# SONATYPE_REPOSITORY_ID: ${{ needs.create_staging_repository.outputs.repository_id }}
# SONATYPE_USERNAME: ${{ secrets.NEXUS_ACTIONS_SONATYPE_USERNAME }}
# SONATYPE_PASSWORD: ${{ secrets.NEXUS_ACTIONS_SONATYPE_PASSWORD }}
# GPG_PRIVATE_KEY: ${{ secrets.NEXUS_ACTIONS_GPG_PRIVATE_KEY }}
# GPG_PRIVATE_PASSWORD: ${{ secrets.NEXUS_ACTIONS_GPG_PRIVATE_PASSWORD }}
#
# create-windows-package:
# runs-on: windows-latest
# needs: create_staging_repository
# steps:
# - name: Checkout
# uses: actions/checkout@v2
# - name: Configure JDK
# uses: actions/setup-java@v1
# with:
# java-version: 14
# - name: Publish
# run: |
# ./gradlew publishMingwX64PublicationToMavenRepository -PSONATYPE_REPOSITORY_ID=${{ needs.create_staging_repository.outputs.repository_id }}
# env:
# MANUAL_REPOSITORY: ${{ secrets.MANUAL_REPOSITORY }}
# SONATYPE_REPOSITORY_ID: ${{ needs.create_staging_repository.outputs.repository_id }}
# SONATYPE_USERNAME: ${{ secrets.NEXUS_ACTIONS_SONATYPE_USERNAME }}
# SONATYPE_PASSWORD: ${{ secrets.NEXUS_ACTIONS_SONATYPE_PASSWORD }}
# GPG_PRIVATE_KEY: ${{ secrets.NEXUS_ACTIONS_GPG_PRIVATE_KEY }}
# GPG_PRIVATE_PASSWORD: ${{ secrets.NEXUS_ACTIONS_GPG_PRIVATE_PASSWORD }}
#
# finalize:
# runs-on: ubuntu-latest
# needs: [create_staging_repository,macos,windows]
# if: ${{ always() && needs.create_staging_repository.result == 'success' }}
# steps:
# - name: Discard
# if: ${{ needs.macos.result != 'success' || needs.windows.result != 'success' }}
# uses: nexus-actions/drop-nexus-staging-repo@main
# with:
# username: ${{ secrets.NEXUS_ACTIONS_SONATYPE_USERNAME }}
# password: ${{ secrets.NEXUS_ACTIONS_SONATYPE_PASSWORD }}
# staging_repository_id: ${{ needs.create_staging_repository.outputs.repository_id }}
# - name: Release
# if: ${{ needs.macos.result == 'success' && needs.windows.result == 'success' }}
# uses: nexus-actions/release-nexus-staging-repo@main
# with:
# base_url: https://s01.oss.sonatype.org/service/local/
# username: ${{ secrets.NEXUS_ACTIONS_SONATYPE_USERNAME }}
# password: ${{ secrets.NEXUS_ACTIONS_SONATYPE_PASSWORD }}
# staging_repository_id: ${{ needs.create_staging_repository.outputs.repository_id }}

2
.gitignore vendored
View File

@ -12,3 +12,5 @@ terraform.tfvars
Gemfile
Gemfile.lock
/maintenance-tasks/build/
/android/.cxx/Debug/5k2s1t1p/x86/
/ffmpeg/ffmpeg-kit-android-lib/.cxx/Debug/

6
.gitmodules vendored
View File

@ -1,6 +1,6 @@
[submodule "spotiflyer-ios"]
path = spotiflyer-ios
url = https://github.com/Shabinder/spotiflyer-ios
[submodule "mosaic"]
path = mosaic
url = https://github.com/JakeWharton/mosaic
[submodule "ffmpeg/ffmpeg-android-maker"]
path = ffmpeg/ffmpeg-android-maker
url = https://github.com/Shabinder/ffmpeg-android-maker/

View File

@ -14,9 +14,9 @@
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Extras.Android.Acra
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import org.jetbrains.compose.compose
import org.jetbrains.kotlin.kapt.cli.main
plugins {
id("com.android.application")
@ -32,14 +32,10 @@ version = Versions.versionName
repositories {
google()
mavenCentral()
// Remove jcenter as soon as following issue closes
// https://github.com/matomo-org/matomo-sdk-android/issues/301
jcenter()
}
android {
val props = gradleLocalProperties(rootDir)
if (props.containsKey("storeFileDir")) {
signingConfigs {
create("release") {
@ -51,17 +47,16 @@ android {
}
}
compileSdkVersion(Versions.compileSdkVersion)
compileSdk = Versions.compileSdkVersion
buildToolsVersion = "30.0.3"
defaultConfig {
applicationId = "com.shabinder.spotiflyer"
minSdkVersion(Versions.minSdkVersion)
targetSdkVersion(Versions.targetSdkVersion)
minSdk = Versions.minSdkVersion
targetSdk = Versions.targetSdkVersion
versionCode = Versions.versionCode
versionName = Versions.versionName
}
buildTypes {
getByName("release") {
isMinifyEnabled = true
@ -69,11 +64,13 @@ android {
if (props.containsKey("storeFileDir")) {
signingConfig = signingConfigs.getByName("release")
}
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
kotlinOptions {
useIR = true
jvmTarget = "1.8"
}
compileOptions {
@ -91,9 +88,6 @@ android {
exclude(group = "androidx.compose.ui")
}
}
packagingOptions {
exclude("META-INF/*")
}
}
dependencies {
implementation(compose.material)
@ -106,6 +100,8 @@ dependencies {
implementation(project(":common:root"))
implementation(project(":common:dependency-injection"))
implementation(project(":common:data-models"))
implementation(project(":common:core-components"))
implementation(project(":common:providers"))
// Koin
implementation(Koin.android)
@ -123,10 +119,8 @@ dependencies {
// Extras
with(Extras.Android) {
implementation(Acra.notification)
implementation(Acra.http)
implementation(countly)
implementation(appUpdator)
implementation(matomo)
}
with(Versions.androidxLifecycle) {
@ -138,7 +132,7 @@ dependencies {
// implementation("com.jakewharton.timber:timber:4.7.1")
implementation("dev.icerock.moko:parcelize:${Versions.mokoParcelize}")
implementation("com.github.shabinder:storage-chooser:2.0.4.45")
implementation("com.google.accompanist:accompanist-insets:0.12.0")
implementation("com.google.accompanist:accompanist-insets:0.16.1")
// Test
testImplementation("junit:junit:4.13.2")

View File

@ -40,6 +40,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application
android:name=".App"
@ -48,11 +49,13 @@
android:usesCleartextTraffic="true"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"
android:icon="@mipmap/ic_launcher"
android:hardwareAccelerated="true"
android:largeHeap="true"
android:label="SpotiFlyer"
android:roundIcon="@mipmap/ic_launcher_round"
android:configChanges="orientation|screenSize"
android:forceDarkAllowed="true"
android:extractNativeLibs="true"
android:requestLegacyExternalStorage="true"
tools:targetApi="q">
<activity android:name=".MainActivity"
@ -73,5 +76,16 @@
</activity>
<service android:name=".service.ForegroundService"/>
<service android:name="org.openudid.OpenUDID_service">
<intent-filter>
<action android:name="org.openudid.GETUDID" />
</intent-filter>
</service>
<receiver android:name="ly.count.android.sdk.ReferrerReceiver" android:exported="true">
<intent-filter>
<action android:name="com.android.vending.INSTALL_REFERRER" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@ -17,22 +17,12 @@
package com.shabinder.spotiflyer
import android.app.Application
import android.content.Context
import com.shabinder.common.di.initKoin
import com.shabinder.common.translations.Strings
import com.shabinder.spotiflyer.di.appModule
import org.acra.config.httpSender
import org.acra.config.notification
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.component.KoinComponent
import org.koin.core.logger.Level
import org.matomo.sdk.Matomo
import org.matomo.sdk.Tracker
import org.matomo.sdk.TrackerBuilder
class App : Application(), KoinComponent {
@ -40,21 +30,6 @@ class App : Application(), KoinComponent {
const val SIGNATURE_HEX = "53304f6d75736a2f30484230334c454b714753525763724259444d3d0a"
}
val tracker: Tracker by lazy {
TrackerBuilder.createDefault(
"https://matomo.spotiflyer.ml/matomo.php", 1
)
.build(Matomo.getInstance(this)).apply {
if (BuildConfig.DEBUG) {
/*Timber.plant(DebugTree())
addTrackingCallback {
Timber.d(it.toMap().toString())
it
}*/
}
}
}
override fun onCreate() {
super.onCreate()
@ -66,35 +41,4 @@ class App : Application(), KoinComponent {
modules(appModule(loggingEnabled))
}
}
@Suppress("SpellCheckingInspection")
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
// Crashlytics
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
/*
* Prompt User Before Sending Any Crash Report
* Obeying `F-Droid Inclusion Privacy Rules`
* */
notification {
title = Strings.acraNotificationTitle()
text = Strings.acraNotificationText()
channelName = "SpotiFlyer_Crashlytics"
channelDescription = "Notification Channel to send Spotiflyer Crashes."
sendOnClick = true
}
// Send Crash Report to self hosted Acrarium (FOSS)
httpSender {
uri = "https://acrarium.spotiflyer.ml/acrarium/report"
basicAuthLogin = "sDj2xCKQIxw0dujf"
basicAuthPassword = "O83du0TsgsDJ69zN"
httpMethod = HttpSender.Method.POST
connectionTimeout = 15000
socketTimeout = 20000
compress = true
}
}
}
}

View File

@ -17,12 +17,7 @@
package com.shabinder.spotiflyer
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.*
import android.content.pm.PackageManager
import android.media.MediaScannerConnection
import android.net.Uri
@ -40,20 +35,14 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.jetbrains.rememberRootComponent
import com.arkivanov.decompose.defaultComponentContext
import com.arkivanov.mvikotlin.logging.store.LoggingStoreFactory
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
import com.codekidlabs.storagechooser.R
@ -62,19 +51,15 @@ import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsPadding
import com.google.accompanist.insets.statusBarsHeight
import com.google.accompanist.insets.statusBarsPadding
import com.shabinder.common.di.ConnectionLiveData
import com.shabinder.common.di.Dir
import com.shabinder.common.di.FetchPlatformQueryResult
import com.shabinder.common.core_components.ConnectionLiveData
import com.shabinder.common.core_components.analytics.AnalyticsManager
import com.shabinder.common.core_components.file_manager.FileManager
import com.shabinder.common.core_components.preference_manager.PreferenceManager
import com.shabinder.common.di.observeAsState
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.models.Actions
import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.PlatformActions
import com.shabinder.common.models.*
import com.shabinder.common.models.PlatformActions.Companion.SharedPreferencesKey
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.methods
import com.shabinder.common.providers.FetchPlatformQueryResult
import com.shabinder.common.root.SpotiFlyerRoot
import com.shabinder.common.root.SpotiFlyerRoot.Analytics
import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks
import com.shabinder.common.translations.Strings
import com.shabinder.common.uikit.configurations.SpotiFlyerTheme
@ -84,33 +69,29 @@ import com.shabinder.spotiflyer.service.ForegroundService
import com.shabinder.spotiflyer.ui.AnalyticsDialog
import com.shabinder.spotiflyer.ui.NetworkDialog
import com.shabinder.spotiflyer.ui.PermissionDialog
import com.shabinder.spotiflyer.utils.checkAppSignature
import com.shabinder.spotiflyer.utils.checkIfLatestVersion
import com.shabinder.spotiflyer.utils.checkPermissions
import com.shabinder.spotiflyer.utils.disableDozeMode
import com.shabinder.spotiflyer.utils.requestStoragePermission
import com.shabinder.spotiflyer.utils.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.matomo.sdk.extra.TrackHelper
import org.koin.core.parameter.parametersOf
import java.io.File
@ExperimentalAnimationApi
class MainActivity : ComponentActivity() {
private val fetcher: FetchPlatformQueryResult by inject()
private val dir: Dir by inject()
private val fileManager: FileManager by inject()
private val preferenceManager: PreferenceManager by inject()
private lateinit var root: SpotiFlyerRoot
private val callBacks: SpotiFlyerRootCallBacks get() = root.callBacks
private val analyticsManager: AnalyticsManager by inject { parametersOf(this) }
private val callBacks: SpotiFlyerRootCallBacks get() = this.rootComponent.callBacks
private val trackStatusFlow = MutableSharedFlow<HashMap<String, DownloadStatus>>(1)
private var permissionGranted = mutableStateOf(true)
private val internetAvailability by lazy { ConnectionLiveData(applicationContext) }
private val tracker get() = (application as App).tracker
private val visibleChild get(): SpotiFlyerRoot.Child = root.routerState.value.activeChild.instance
private lateinit var rootComponent: SpotiFlyerRoot
// private val visibleChild get(): SpotiFlyerRoot.Child = root.routerState.value.activeChild.instance
// Variable for storing instance of our service class
var foregroundService: ForegroundService? = null
@ -120,9 +101,10 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
preferenceManager.analyticsManager = analyticsManager
// This app draws behind the system bars, so we want to handle fitting system windows
WindowCompat.setDecorFitsSystemWindows(window, false)
this.rootComponent = spotiFlyerRoot(defaultComponentContext())
setContent {
SpotiFlyerTheme {
Surface(contentColor = colorOffWhite) {
@ -131,8 +113,8 @@ class MainActivity : ComponentActivity() {
val view = LocalView.current
Box {
root = SpotiFlyerRootContent(
rememberRootComponent(::spotiFlyerRoot),
SpotiFlyerRootContent(
this@MainActivity.rootComponent,
Modifier.statusBarsPadding().navigationBarsPadding()
)
Spacer(
@ -186,11 +168,10 @@ class MainActivity : ComponentActivity() {
* and Track Downloads for all other releases like F-Droid,
* for `Github Downloads` we will track Downloads using : https://tooomm.github.io/github-release-stats/?username=Shabinder&repository=SpotiFlyer
* */
if (isGithubRelease) { checkIfLatestVersion() }
if (preferenceManager.isAnalyticsEnabled && !isGithubRelease) {
// Download/App Install Event for F-Droid builds
TrackHelper.track().download().with(tracker)
if (isGithubRelease) {
checkIfLatestVersion()
}
// TODO Track Download Event
handleIntentFromExternalActivity()
initForegroundService()
@ -260,7 +241,12 @@ class MainActivity : ComponentActivity() {
).show()
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
@Suppress("DEPRECATION")
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
permissionGranted.value = checkPermissions()
}
@ -270,15 +256,18 @@ class MainActivity : ComponentActivity() {
componentContext,
dependencies = object : SpotiFlyerRoot.Dependencies {
override val storeFactory = LoggingStoreFactory(DefaultStoreFactory)
override val database = this@MainActivity.dir.db
override val database = this@MainActivity.fileManager.db
override val fetchQuery = this@MainActivity.fetcher
override val dir: Dir = this@MainActivity.dir
override val fileManager: FileManager = this@MainActivity.fileManager
override val preferenceManager = this@MainActivity.preferenceManager
override val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>> = trackStatusFlow
override val analyticsManager: AnalyticsManager = this@MainActivity.analyticsManager
override val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>> =
trackStatusFlow
override val actions = object : Actions {
override val platformActions = object : PlatformActions {
override val imageCacheDir: String = applicationContext.cacheDir.absolutePath + File.separator
override val imageCacheDir: String =
applicationContext.cacheDir.absolutePath + File.separator
override val sharedPreferences = applicationContext.getSharedPreferences(
SharedPreferencesKey,
MODE_PRIVATE
@ -292,19 +281,26 @@ class MainActivity : ComponentActivity() {
}
override fun sendTracksToService(array: List<TrackDetails>) {
for (chunk in array.chunked(25)) {
if (foregroundService == null) initForegroundService()
foregroundService?.downloadAllTracks(array)
foregroundService?.downloadAllTracks(chunk)
}
}
}
override fun showPopUpMessage(string: String, long: Boolean) = this@MainActivity.showPopUpMessage(string, long)
override fun showPopUpMessage(string: String, long: Boolean) =
this@MainActivity.showPopUpMessage(string, long)
override fun setDownloadDirectoryAction(callBack: (String) -> Unit) = setUpOnPrefClickListener(callBack)
override fun setDownloadDirectoryAction(callBack: (String) -> Unit) =
setUpOnPrefClickListener(callBack)
override fun queryActiveTracks() = this@MainActivity.queryActiveTracks()
override fun giveDonation() {
openPlatform("", platformLink = "https://razorpay.com/payment-button/pl_GnKuuDBdBu0ank/view/?utm_source=payment_button&utm_medium=button&utm_campaign=payment_button")
openPlatform(
"",
platformLink = "https://razorpay.com/payment-button/pl_GnKuuDBdBu0ank/view/?utm_source=payment_button&utm_medium=button&utm_campaign=payment_button"
)
}
override fun shareApp() {
@ -341,47 +337,12 @@ class MainActivity : ComponentActivity() {
}
}
override fun writeMp3Tags(trackDetails: TrackDetails) { /*IMPLEMENTED*/ }
override fun writeMp3Tags(trackDetails: TrackDetails) {
/*IMPLEMENTED*/
}
override val isInternetAvailable get() = internetAvailability.value ?: true
}
/*
* Analytics Will Only Be Sent if User Granted us the Permission
* */
override val analytics = object : Analytics {
override fun appLaunchEvent() {
if (preferenceManager.isAnalyticsEnabled) {
TrackHelper.track()
.event("events", "App_Launch")
.name("App Launch").with(tracker)
}
}
override fun homeScreenVisit() {
if (preferenceManager.isAnalyticsEnabled) {
// HomeScreen Visit Event
TrackHelper.track().screen("/main_activity/home_screen")
.title("HomeScreen").with(tracker)
}
}
override fun listScreenVisit() {
if (preferenceManager.isAnalyticsEnabled) {
// ListScreen Visit Event
TrackHelper.track().screen("/main_activity/list_screen")
.title("ListScreen").with(tracker)
}
}
override fun donationDialogVisit() {
if (preferenceManager.isAnalyticsEnabled) {
// Donation Dialog Open Event
TrackHelper.track().screen("/main_activity/donation_dialog")
.title("DonationDialog").with(tracker)
}
}
}
}
)
@ -423,7 +384,7 @@ class MainActivity : ComponentActivity() {
// hell yeah :)
preferenceManager.setDownloadDirectory(path)
callBack(path)
showPopUpMessage(Strings.downloadDirectorySetTo("\n${dir.defaultDir()}"))
showPopUpMessage(Strings.downloadDirectorySetTo("\n${fileManager.defaultDir()}"))
} else {
showPopUpMessage(Strings.noWriteAccess("\n$path "))
}
@ -433,6 +394,7 @@ class MainActivity : ComponentActivity() {
chooser.show()
}
@Suppress("DEPRECATION")
@SuppressLint("ObsoleteSdkInt")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
@ -466,10 +428,10 @@ class MainActivity : ComponentActivity() {
val link = filterLinkRegex.find(string)?.value.toString()
Log.i("Intent", link)
lifecycleScope.launch {
while (!this@MainActivity::root.isInitialized) {
while (!this@MainActivity::rootComponent.isInitialized) {
delay(100)
}
if (methods.value.isInternetAvailable)callBacks.searchLink(link)
if (methods.value.isInternetAvailable) callBacks.searchLink(link)
}
}
}
@ -481,6 +443,16 @@ class MainActivity : ComponentActivity() {
unbindService()
}
override fun onStart() {
super.onStart()
analyticsManager.onStart()
}
override fun onStop() {
super.onStop()
analyticsManager.onStop()
}
companion object {
const val disableDozeCode = 1223
}

View File

@ -33,19 +33,19 @@ import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Kermit
import com.shabinder.common.di.Dir
import com.shabinder.common.di.FetchPlatformQueryResult
import com.shabinder.common.di.R
import com.shabinder.common.di.downloadFile
import com.shabinder.common.di.utils.ParallelExecutor
import com.shabinder.common.core_components.file_manager.FileManager
import com.shabinder.common.core_components.file_manager.downloadFile
import com.shabinder.common.core_components.parallel_executor.ParallelExecutor
import com.shabinder.common.models.DownloadResult
import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.event.coroutines.failure
import com.shabinder.common.providers.FetchPlatformQueryResult
import com.shabinder.common.translations.Strings
import com.shabinder.spotiflyer.utils.autoclear.AutoClear
import com.shabinder.spotiflyer.R
import com.shabinder.spotiflyer.utils.autoclear.autoClear
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
@ -56,13 +56,19 @@ import java.io.File
class ForegroundService : LifecycleService() {
private var downloadService: AutoClear<ParallelExecutor> = autoClear { ParallelExecutor(Dispatchers.IO) }
val trackStatusFlowMap by autoClear { TrackStatusFlowMap(MutableSharedFlow(replay = 1), lifecycleScope) }
private lateinit var downloadService: ParallelExecutor
val trackStatusFlowMap by autoClear {
TrackStatusFlowMap(
MutableSharedFlow(replay = 1),
lifecycleScope
)
}
private val fetcher: FetchPlatformQueryResult by inject()
private val logger: Kermit by inject()
private val dir: Dir by inject()
private val dir: FileManager by inject()
private var messageList = java.util.Collections.synchronizedList(MutableList(5) { emptyMessage })
private var messageList =
java.util.Collections.synchronizedList(MutableList(5) { emptyMessage })
private var wakeLock: PowerManager.WakeLock? = null
private var isServiceStarted = false
private val cancelIntent: PendingIntent by lazy {
@ -81,6 +87,7 @@ class ForegroundService : LifecycleService() {
inner class DownloadServiceBinder : Binder() {
val service get() = this@ForegroundService
}
private val myBinder: IBinder = DownloadServiceBinder()
override fun onBind(intent: Intent): IBinder {
@ -90,12 +97,14 @@ class ForegroundService : LifecycleService() {
override fun onCreate() {
super.onCreate()
downloadService = ParallelExecutor(Dispatchers.IO)
createNotificationChannel(CHANNEL_ID, "Downloader Service")
}
@SuppressLint("WakelockTimeout")
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
downloadService.reviveIfClosed()
// Send a notification that service is started
Log.i(TAG, "Foreground Service Started.")
startForeground(NOTIFICATION_ID, createNotification())
@ -127,6 +136,7 @@ class ForegroundService : LifecycleService() {
* Function To Download All Tracks Available in a List
**/
fun downloadAllTracks(trackList: List<TrackDetails>) {
downloadService.reviveIfClosed()
trackList.size.also { size ->
total += size
isSingleDownload = (size == 1)
@ -136,10 +146,10 @@ class ForegroundService : LifecycleService() {
for (track in trackList) {
trackStatusFlowMap[track.title] = DownloadStatus.Queued
lifecycleScope.launch {
downloadService.value.execute {
fetcher.findMp3DownloadLink(track).fold(
success = { url ->
enqueueDownload(url, track)
downloadService.executeSuspending {
fetcher.findBestDownloadLink(track).fold(
success = { res ->
enqueueDownload(res.first, track.apply { audioQuality = res.second })
},
failure = { error ->
failed++
@ -163,7 +173,8 @@ class ForegroundService : LifecycleService() {
is DownloadResult.Error -> {
logger.d(TAG) { it.message }
failed++
trackStatusFlowMap[track.title] = DownloadStatus.Failed(it.cause ?: Exception(it.message))
trackStatusFlowMap[track.title] =
DownloadStatus.Failed(it.cause ?: Exception(it.message))
removeFromNotification(Message(track.title, DownloadStatus.Downloading()))
}
@ -176,17 +187,40 @@ class ForegroundService : LifecycleService() {
coroutineScope {
SuspendableEvent {
// Save File and Embed Metadata
val job = launch(Dispatchers.Default) { dir.saveFileWithMetadata(it.byteArray, track) {} }
val job = launch(Dispatchers.Default) {
dir.saveFileWithMetadata(
it.byteArray,
track
) {}
}
// Send Converting Status
trackStatusFlowMap[track.title] = DownloadStatus.Converting
addToNotification(Message(track.title, DownloadStatus.Converting))
// All Processing Completed for this Track
job.invokeOnCompletion {
job.invokeOnCompletion { throwable ->
if (throwable != null /*&& throwable !is CancellationException*/) {
// handle error
failed++
trackStatusFlowMap[track.title] =
DownloadStatus.Failed(throwable)
removeFromNotification(
Message(
track.title,
DownloadStatus.Converting
)
)
return@invokeOnCompletion
}
converted++
trackStatusFlowMap[track.title] = DownloadStatus.Downloaded
removeFromNotification(Message(track.title, DownloadStatus.Converting))
removeFromNotification(
Message(
track.title,
DownloadStatus.Converting
)
)
}
logger.d(TAG) { "${track.title} Download Completed" }
downloaded++
@ -240,9 +274,9 @@ class ForegroundService : LifecycleService() {
messageList = messageList.getEmpty().apply {
set(index = 0, Message(Strings.cleaningAndExiting(), DownloadStatus.NotDownloaded))
}
downloadService.getOrNull()?.close()
downloadService.reset()
downloadService.close()
updateNotification()
trackStatusFlowMap.clear()
cleanFiles(File(dir.defaultDir()))
// cleanFiles(File(dir.imageCacheDir()))
messageList = messageList.getEmpty()
@ -256,7 +290,8 @@ class ForegroundService : LifecycleService() {
}
}
private fun createNotification(): Notification = NotificationCompat.Builder(this, CHANNEL_ID).run {
private fun createNotification(): Notification =
NotificationCompat.Builder(this, CHANNEL_ID).run {
setSmallIcon(R.drawable.ic_download_arrow)
setContentTitle("${Strings.total()}: $total ${Strings.completed()}:$converted ${Strings.failed()}:$failed")
setSilent(true)
@ -304,12 +339,16 @@ class ForegroundService : LifecycleService() {
override fun onDestroy() {
super.onDestroy()
if (isFinished) { killService() }
if (isFinished) {
killService()
}
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
if (isFinished) { killService() }
if (isFinished) {
killService()
}
}
companion object {

View File

@ -10,8 +10,28 @@ class TrackStatusFlowMap(
private val scope: CoroutineScope
) : HashMap<String, DownloadStatus>() {
override fun put(key: String, value: DownloadStatus): DownloadStatus? {
synchronized(this) {
val res = super.put(key, value)
scope.launch { statusFlow.emit(this@TrackStatusFlowMap) }
emitValue()
return res
}
}
override fun clear() {
synchronized(this) {
// Reset Statuses
this.forEach { (title, status) ->
if(status !is DownloadStatus.Failed && status !is DownloadStatus.Downloaded) {
super.put(title,DownloadStatus.NotDownloaded)
}
}
emitValue()
//super.clear()
//emitValue()
}
}
private fun emitValue() {
scope.launch { statusFlow.emit(this@TrackStatusFlowMap) }
}
}

View File

@ -27,9 +27,14 @@ allprojects {
// mavenLocal()
maven(url = "https://jitpack.io")
maven(url = "https://maven.pkg.jetbrains.space/public/p/compose/dev")
maven(url = "https://dl.bintray.com/kotlin/kotlin-js-wrappers")
maven(url = "https://maven.pkg.jetbrains.space/public/p/kotlinx-html/maven")
}
/*Fixes: Could not resolve org.nodejs:node*/
plugins.withType<org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin> {
configure<org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension> {
download = false
}
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
dependsOn(":common:data-models:generateI18n4kFiles")
kotlinOptions { jvmTarget = "1.8" }

View File

@ -16,15 +16,20 @@
@file:Suppress("MayBeConstant", "SpellCheckingInspection")
import org.gradle.api.artifacts.ExternalModuleDependency
import org.gradle.api.artifacts.dsl.DependencyHandler
import org.gradle.kotlin.dsl.accessors.runtime.addDependencyTo
object Versions {
// App's Version (To be bumped at each update)
const val versionName = "3.2.1"
const val versionName = "3.3.0"
const val versionCode = 24
const val versionCode = 22
// Kotlin
const val kotlinVersion = "1.5.10"
const val kotlinVersion = "1.5.21"
const val coroutinesVersion = "1.5.0"
const val coroutinesVersion = "1.5.1"
// Code Formatting
const val ktLint = "10.1.0"
@ -41,38 +46,46 @@ object Versions {
const val mokoParcelize = "0.7.1"
// Internet
const val ktor = "1.6.0"
const val ktor = "1.6.2"
const val kotlinxSerialization = "1.2.1"
const val kotlinxSerialization = "1.2.2"
// Database
const val sqlDelight = "1.5.0"
const val sqlDelight = "1.5.1"
const val sqliteJdbcDriver = "3.34.0"
const val slf4j = "1.7.31"
// Internationalisation
const val i18n4k = "0.1.2"
const val i18n4k = "0.1.3"
// Android
const val minSdkVersion = 21
const val compileSdkVersion = 30
const val targetSdkVersion = 29
const val androidxLifecycle = "2.3.1"
const val androidxLifecycle = "2.4.0-alpha03"
}
object HostOS {
// Host OS Properties
private val hostOs = System.getProperty("os.name")
val isMingwX64 = hostOs.startsWith("Windows",true)
val isMac = hostOs.startsWith("Mac",true)
val isLinux = hostOs.startsWith("Linux",true)
val isMingwX64 = hostOs.startsWith("Windows", true)
val isMac = hostOs.startsWith("Mac", true)
val isLinux = hostOs.startsWith("Linux", true)
}
object MultiPlatformSettings {
const val dep = "com.russhwolf:multiplatform-settings-no-arg:0.7.7"
}
object KotlinJSWrappers {
private const val bomVersion = "0.0.1-pre.235-kotlin-1.5.21"
val bom = "org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom:${bomVersion}"
const val kotlinReact = "org.jetbrains.kotlin-wrappers:kotlin-react"
const val kotlinReactDom = "org.jetbrains.kotlin-wrappers:kotlin-react-dom"
const val kotlinStyled = "org.jetbrains.kotlin-wrappers:kotlin-styled"
}
object Koin {
val core = "io.insert-koin:koin-core:${Versions.koin}"
val test = "io.insert-koin:koin-test:${Versions.koin}"
@ -81,15 +94,15 @@ object Koin {
}
object Androidx {
const val androidxActivity = "androidx.activity:activity-compose:1.3.0-beta02"
const val core = "androidx.core:core-ktx:1.5.0"
const val androidxActivity = "androidx.activity:activity-compose:1.3.1"
const val core = "androidx.core:core-ktx:1.6.0"
const val palette = "androidx.palette:palette-ktx:1.0.0"
const val coroutines = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutinesVersion}"
const val junit = "androidx.test.ext:junit:1.1.2"
const val expresso = "androidx.test.espresso:espresso-core:3.3.0"
const val gradlePlugin = "com.android.tools.build:gradle:4.1.1"
const val gradlePlugin = "com.android.tools.build:gradle:7.0.1"
}
object KTLint {
@ -98,24 +111,28 @@ object KTLint {
object JetBrains {
object Kotlin {
const val coroutines = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1-native-mt"
const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlinVersion}"
const val serialization = "org.jetbrains.kotlin:kotlin-serialization:${Versions.kotlinVersion}"
const val testCommon = "org.jetbrains.kotlin:kotlin-test-common:${Versions.kotlinVersion}"
const val testJunit = "org.jetbrains.kotlin:kotlin-test-junit:${Versions.kotlinVersion}"
const val testAnnotationsCommon = "org.jetbrains.kotlin:kotlin-test-annotations-common:${Versions.kotlinVersion}"
const val testAnnotationsCommon =
"org.jetbrains.kotlin:kotlin-test-annotations-common:${Versions.kotlinVersion}"
}
object Compose {
// __LATEST_COMPOSE_RELEASE_VERSION__
const val VERSION = "0.4.0"
private const val VERSION = "1.0.0-alpha2"
const val gradlePlugin = "org.jetbrains.compose:compose-gradle-plugin:$VERSION"
}
}
object Mosaic {
const val gradlePlugin = "com.jakewharton.mosaic:mosaic-gradle-plugin:${Versions.mosaic}"
}
object Decompose {
private const val VERSION = "0.2.6"
private const val VERSION = "0.3.1"
const val decompose = "com.arkivanov.decompose:decompose:$VERSION"
const val decomposeIosX64 = "com.arkivanov.decompose:decompose-iosx64:$VERSION"
const val decomposeIosArm64 = "com.arkivanov.decompose:decompose-iosarm64:$VERSION"
@ -163,17 +180,16 @@ object Extras {
const val mp3agic = "com.mpatric:mp3agic:0.9.0"
const val jaudioTagger = "com.github.Shabinder:JAudioTagger-Android:1.0"
const val kermit = "co.touchlab:kermit:${Versions.kermit}"
object Android {
object Acra {
// Self Hosted Crashlytics (FOSS)
private const val VERSION = "5.8.3"
val http = "ch.acra:acra-http:$VERSION"
val notification = "ch.acra:acra-notification:$VERSION"
}
// Self Hosted Analytics (FOSS)
val matomo = "org.matomo.sdk:tracker:4.1.2"
// Self Hosted Analytics & Crashlytics (FOSS)
val countly = "ly.count.android:sdk:20.11.8"
val appUpdator = "com.github.amitbd1508:AppUpdater:4.1.0"
}
object Desktop {
val countly = "ly.count.sdk:java:20.11.0"
}
}
object Serialization {
@ -191,3 +207,10 @@ object SqlDelight {
val nativeDriverMacos = "com.squareup.sqldelight:native-driver-macosx64:${Versions.sqlDelight}"
val jdbcDriver = "org.xerial:sqlite-jdbc:${Versions.sqliteJdbcDriver}"
}
fun DependencyHandler.`implementation`(
dependencyNotation: String,
dependencyConfiguration: ExternalModuleDependency.() -> Unit
): ExternalModuleDependency = addDependencyTo(
this, "implementation", dependencyNotation
) { dependencyConfiguration() }

View File

@ -22,11 +22,11 @@ plugins {
}
android {
compileSdkVersion(Versions.compileSdkVersion)
compileSdk = Versions.compileSdkVersion
defaultConfig {
minSdkVersion(Versions.minSdkVersion)
targetSdkVersion(Versions.targetSdkVersion)
minSdk = Versions.minSdkVersion
targetSdk = Versions.targetSdkVersion
}
compileOptions {
@ -37,7 +37,6 @@ android {
sourceSets {
named("main") {
manifest.srcFile("src/androidMain/AndroidManifest.xml")
java.srcDirs("src/androidMain/kotlin")
res.srcDirs("src/androidMain/res")
}
}

View File

@ -43,7 +43,7 @@ kotlin {
implementation(Extras.kermit)
implementation("dev.icerock.moko:parcelize:${Versions.mokoParcelize}")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0-native-mt") {
implementation(JetBrains.Kotlin.coroutines) {
@Suppress("DEPRECATION")
isForce = true
}
@ -51,7 +51,7 @@ kotlin {
}
named("androidMain") {
dependencies {
implementation("androidx.appcompat:appcompat:1.3.0")
implementation(Androidx.androidxActivity)
implementation(Androidx.core)
}
}

View File

@ -41,19 +41,19 @@ kotlin {
sourceSets {
named("commonTest") {
dependencies {
//implementation(JetBrains.Kotlin.testCommon)
//implementation(JetBrains.Kotlin.testAnnotationsCommon)
implementation(JetBrains.Kotlin.testCommon)
implementation(JetBrains.Kotlin.testAnnotationsCommon)
}
}
named("androidTest") {
dependencies {
//implementation(JetBrains.Kotlin.testJunit)
implementation(JetBrains.Kotlin.testJunit)
}
}
named("desktopTest") {
dependencies {
//implementation(JetBrains.Kotlin.testJunit)
implementation(JetBrains.Kotlin.testJunit)
}
}
named("jsTest") {

View File

@ -38,7 +38,11 @@ kotlin {
android()
js(BOTH) {
browser()
browser {
commonWebpackConfig {
cssSupport.enabled = true
}
}
// nodejs()
}
@ -66,7 +70,7 @@ kotlin {
implementation(Serialization.json)
implementation("co.touchlab:stately-common:1.1.7")
implementation("dev.icerock.moko:parcelize:${Versions.mokoParcelize}")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0-native-mt") {
implementation(JetBrains.Kotlin.coroutines) {
@Suppress("DEPRECATION")
isForce = true
}
@ -75,7 +79,7 @@ kotlin {
named("androidMain") {
dependencies {
implementation("androidx.appcompat:appcompat:1.3.0")
implementation(Androidx.androidxActivity)
implementation(Androidx.core)
implementation(compose.runtime)
implementation(compose.material)
@ -102,9 +106,13 @@ kotlin {
named("jsMain") {
dependencies {
implementation(Ktor.clientJs)
implementation("org.jetbrains.kotlin-wrappers:kotlin-react:17.0.2-pre.213-kotlin-1.5.10")
implementation("org.jetbrains.kotlin-wrappers:kotlin-react-dom:17.0.2-pre.213-kotlin-1.5.10")
implementation("org.jetbrains.kotlin-wrappers:kotlin-styled:5.3.0-pre.213-kotlin-1.5.10")
/*with(KotlinJSWrappers) {
implementation(enforcedPlatform(bom))
implementation(kotlinReact)
implementation(kotlinReactDom)
implementation(kotlinStyled)
}*/
}
}
if(HostOS.isMac){

View File

@ -34,6 +34,7 @@ kotlin {
implementation(project(":common:main"))
implementation(project(":common:list"))
implementation(project(":common:preference"))
implementation(project(":common:core-components"))
implementation(project(":common:database"))
implementation(project(":common:data-models"))
implementation(project(":common:dependency-injection"))

View File

@ -11,8 +11,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import com.shabinder.common.di.Picture
import com.shabinder.common.di.dispatcherIO
import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.models.dispatcherIO
import kotlinx.coroutines.withContext
@Composable

View File

@ -3,7 +3,7 @@ package com.shabinder.common.uikit.configurations
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import com.shabinder.common.database.R
import com.shabinder.common.models.R
actual fun montserratFont() = FontFamily(
Font(R.font.montserrat_light, FontWeight.Light),
@ -12,6 +12,6 @@ actual fun montserratFont() = FontFamily(
Font(R.font.montserrat_semibold, FontWeight.SemiBold),
)
actual fun pristineFont() = FontFamily(
actual fun pristineFont(): FontFamily = FontFamily(
Font(R.font.pristine_script, FontWeight.Bold)
)

View File

@ -2,7 +2,7 @@ package com.shabinder.common.uikit
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.shabinder.common.di.Picture
import com.shabinder.common.core_components.picture.Picture
@Composable
expect fun ImageLoad(

View File

@ -53,7 +53,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState
import com.shabinder.common.di.Picture
import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.list.SpotiFlyerList
import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.TrackDetails

View File

@ -78,7 +78,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState
import com.shabinder.common.di.Picture
import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.main.SpotiFlyerMain
import com.shabinder.common.main.SpotiFlyerMain.HomeCategory
import com.shabinder.common.models.DownloadRecord

View File

@ -69,7 +69,9 @@ fun SpotiFlyerPreferenceContent(component: SpotiFlyerPreference) {
title = "Preferred Audio Quality",
value = model.preferredQuality.kbps + "KBPS"
) { save ->
val audioQualities = AudioQuality.values()
val audioQualities = AudioQuality.values().toMutableList().apply {
remove(AudioQuality.UNKNOWN)
}
audioQualities.forEach { quality ->
Row(

View File

@ -12,7 +12,7 @@ actual fun Dialog(
content: @Composable () -> Unit
) {
AnimatedVisibility(isVisible) {
androidx.compose.ui.window.v1.Dialog(onDismiss) {
androidx.compose.ui.window.Dialog(onDismiss) {
content()
}
}

View File

@ -2,17 +2,12 @@ package com.shabinder.common.uikit
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import com.shabinder.common.di.Picture
import com.shabinder.common.di.dispatcherIO
import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.models.dispatcherIO
import kotlinx.coroutines.withContext
@Composable
@ -31,6 +26,11 @@ actual fun ImageLoad(
}
Crossfade(pic) {
if (it == null) Image(PlaceHolderImage(), desc, modifier, contentScale = ContentScale.Crop) else Image(it, desc, modifier, contentScale = ContentScale.Crop)
if (it == null) Image(PlaceHolderImage(), desc, modifier, contentScale = ContentScale.Crop) else Image(
it,
desc,
modifier,
contentScale = ContentScale.Crop
)
}
}

View File

@ -0,0 +1,39 @@
plugins {
id("multiplatform-setup")
id("multiplatform-setup-test")
kotlin("plugin.serialization")
}
kotlin {
sourceSets {
commonMain {
dependencies {
implementation(project(":common:data-models"))
implementation(project(":common:database"))
implementation("org.jetbrains.kotlinx:atomicfu:0.16.2")
api(MultiPlatformSettings.dep)
implementation(MVIKotlin.rx)
}
}
androidMain {
dependencies {
implementation(Extras.mp3agic)
implementation(Extras.Android.countly)
implementation(project(":ffmpeg:android-ffmpeg"))
}
}
desktopMain {
dependencies {
implementation(Extras.mp3agic)
implementation(Extras.Desktop.countly)
implementation("com.github.kokorin.jaffree:jaffree:2021.08.16")
}
}
jsMain {
dependencies {
implementation(npm("browser-id3-writer", "4.4.0"))
implementation(npm("file-saver", "2.0.4"))
}
}
}
}

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ * 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/>.
-->
<manifest package="com.shabinder.common.core_components" xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>

View File

@ -14,7 +14,7 @@
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.shabinder.common.di
package com.shabinder.common.core_components
import android.content.Context
import android.content.Context.CONNECTIVITY_SERVICE
@ -24,6 +24,7 @@ import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
import android.net.NetworkRequest
import android.util.Log
import androidx.lifecycle.LiveData
import com.shabinder.common.core_components.utils.isInternetAccessible
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

View File

@ -0,0 +1,71 @@
package com.shabinder.common.core_components.analytics
import android.app.Activity
import android.app.Application
import ly.count.android.sdk.Countly
import ly.count.android.sdk.CountlyConfig
import ly.count.android.sdk.DeviceId
import org.koin.dsl.bind
import org.koin.dsl.module
internal class AndroidAnalyticsManager(private val mainActivity: Activity) : AnalyticsManager {
init {
init()
}
override fun init() {
Countly.sharedInstance().init(
CountlyConfig(
mainActivity.applicationContext as Application,
COUNTLY_CONFIG.APP_KEY,
COUNTLY_CONFIG.SERVER_URL
).apply {
setIdMode(DeviceId.Type.OPEN_UDID)
setViewTracking(true)
enableCrashReporting()
setLoggingEnabled(false)
setRecordAllThreadsWithCrash()
setRequiresConsent(true)
setShouldIgnoreAppCrawlers(true)
setEventQueueSizeToSend(5)
}
)
}
override fun onStart() {
Countly.sharedInstance().onStart(mainActivity)
}
override fun onStop() {
Countly.sharedInstance().onStop()
}
override fun giveConsent() {
Countly.sharedInstance().consent().giveConsentAll()
}
override fun isTracking(): Boolean = Countly.sharedInstance().consent().getConsent(Countly.CountlyFeatureNames.events)
override fun revokeConsent() {
Countly.sharedInstance().consent().removeConsentAll()
}
override fun sendView(name: String, extras: MutableMap<String, Any>) {
Countly.sharedInstance().views().recordView(name, extras)
}
override fun sendEvent(eventName: String, extras: MutableMap<String, Any>) {
Countly.sharedInstance().events().recordEvent(eventName, extras)
}
override fun sendCrashReport(error: Throwable, extras: MutableMap<String, Any>) {
Countly.sharedInstance().crashes().recordUnhandledException(error, extras)
}
}
internal actual fun analyticsModule() = module {
factory { (mainActivity: Activity) ->
AndroidAnalyticsManager(mainActivity)
} bind AnalyticsManager::class
}

View File

@ -0,0 +1,232 @@
/*
* * 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.core_components.file_manager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Environment
import androidx.compose.ui.graphics.asImageBitmap
import co.touchlab.kermit.Kermit
import com.mpatric.mp3agic.InvalidDataException
import com.mpatric.mp3agic.Mp3File
import com.shabinder.common.core_components.media_converter.MediaConverter
import com.shabinder.common.core_components.media_converter.removeAllTags
import com.shabinder.common.core_components.media_converter.setId3v1Tags
import com.shabinder.common.core_components.media_converter.setId3v2TagsAndSaveFile
import com.shabinder.common.core_components.parallel_executor.ParallelExecutor
import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.core_components.preference_manager.PreferenceManager
import com.shabinder.common.database.SpotiFlyerDatabase
import com.shabinder.common.di.getMemoryEfficientBitmap
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.dispatcherIO
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.event.coroutines.failure
import com.shabinder.common.models.event.coroutines.map
import com.shabinder.common.models.methods
import com.shabinder.database.Database
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.dsl.bind
import org.koin.dsl.module
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
internal actual fun fileManagerModule() = module {
single { AndroidFileManager(get(), get(), get(), get()) } bind FileManager::class
}
/*
* Ignore Deprecation
* `Deprecation is only a Suggestion P->`
* */
@Suppress("DEPRECATION")
class AndroidFileManager(
override val logger: Kermit,
override val preferenceManager: PreferenceManager,
override val mediaConverter: MediaConverter,
spotiFlyerDatabase: SpotiFlyerDatabase
) : FileManager {
@Suppress("DEPRECATION")
private val defaultBaseDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC).toString()
override fun fileSeparator(): String = File.separator
override fun imageCacheDir(): String = methods.value.platformActions.imageCacheDir
// fun call in order to always access Updated Value
override fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) +
File.separator + "SpotiFlyer" + File.separator
override fun isPresent(path: String): Boolean = File(path).exists()
override fun createDirectory(dirPath: String) {
val yourAppDir = File(dirPath)
if (!yourAppDir.exists() && !yourAppDir.isDirectory) { // create empty directory
if (yourAppDir.mkdirs()) {
logger.i { "$dirPath created" }
} else {
logger.e { "Unable to create Dir: $dirPath!" }
}
} else {
logger.i { "$dirPath already exists" }
}
}
@Suppress("unused")
override suspend fun clearCache(): Unit = withContext(dispatcherIO) {
File(imageCacheDir()).deleteRecursively()
}
@Suppress("BlockingMethodInNonBlockingContext")
override suspend fun saveFileWithMetadata(
mp3ByteArray: ByteArray,
trackDetails: TrackDetails,
postProcess: (track: TrackDetails) -> Unit
) = withContext(dispatcherIO) {
val songFile = File(trackDetails.outputFilePath)
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*/
songFile.parentFile?.mkdirs()
}
// Write Bytes to Media File
songFile.writeBytes(mp3ByteArray)
try {
// Add Mp3 Tags and Add to Library
Mp3File(File(songFile.absolutePath))
.removeAllTags()
.setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails)
addToLibrary(songFile.absolutePath)
} catch (e: Exception) {
// Media File Isn't MP3 lets Convert It first
if (e is InvalidDataException) {
val convertedFilePath =
songFile.absolutePath.substringBeforeLast('.') + ".temp.mp3"
val conversionResult = mediaConverter.convertAudioFile(
inputFilePath = songFile.absolutePath,
outputFilePath = convertedFilePath,
trackDetails.audioQuality
)
conversionResult.map { outputFilePath ->
Mp3File(File(outputFilePath))
.removeAllTags()
.setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails, trackDetails.outputFilePath)
addToLibrary(trackDetails.outputFilePath)
}.fold(
success = {},
failure = {
throw it
}
)
File(convertedFilePath).delete()
} else throw e
}
SuspendableEvent.success(trackDetails.outputFilePath)
} catch (e: Throwable) {
e.printStackTrace()
if (songFile.exists()) songFile.delete()
logger.e { "${songFile.absolutePath} could not be created" }
SuspendableEvent.error(e)
}
}
override fun addToLibrary(path: String) = methods.value.platformActions.addToLibrary(path)
override suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture =
withContext(dispatcherIO) {
val cachePath = imageCacheDir() + getNameURL(url)
Picture(
image = (loadCachedImage(cachePath, reqWidth, reqHeight) ?: freshImage(
url,
reqWidth,
reqHeight
))?.asImageBitmap()
)
}
private fun loadCachedImage(cachePath: String, reqWidth: Int, reqHeight: Int): Bitmap? {
return try {
getMemoryEfficientBitmap(cachePath, reqWidth, reqHeight)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
@Suppress("BlockingMethodInNonBlockingContext")
override suspend fun cacheImage(image: Any, path: String): Unit = withContext(dispatcherIO) {
try {
FileOutputStream(path).use { out ->
(image as? Bitmap)?.compress(Bitmap.CompressFormat.JPEG, 100, out)
}
} catch (e: IOException) {
e.printStackTrace()
}
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun freshImage(url: String, reqWidth: Int, reqHeight: Int): Bitmap? =
withContext(dispatcherIO) {
try {
val source = URL(url)
val connection: HttpURLConnection = source.openConnection() as HttpURLConnection
connection.connectTimeout = 5000
connection.connect()
val input: ByteArray = connection.inputStream.readBytes()
// Get Memory Efficient Bitmap
val bitmap: Bitmap? = getMemoryEfficientBitmap(input, reqWidth, reqHeight)
parallelExecutor.executeSuspending {
// Decode and Cache Full Sized Image in Background
cacheImage(
BitmapFactory.decodeByteArray(input, 0, input.size),
imageCacheDir() + getNameURL(url)
)
}
bitmap // return Memory Efficient Bitmap
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/*
* Parallel Executor with 2 concurrent operation at a time.
* - We will use this to queue up operations and decode Full Sized Images
* - Will Decode Only a small set of images at a time , to avoid going into `Out of Memory`
* */
private val parallelExecutor = ParallelExecutor(Dispatchers.IO, 2)
override val db: Database? = spotiFlyerDatabase.instance
}

View File

@ -0,0 +1,71 @@
package com.shabinder.common.core_components.media_converter
import android.content.Context
import android.util.Log
import com.shabinder.common.models.AudioQuality
import com.shabinder.common.models.SpotiFlyerException
import kotlinx.coroutines.delay
import nl.bravobit.ffmpeg.ExecuteBinaryResponseHandler
import nl.bravobit.ffmpeg.FFmpeg
import org.koin.dsl.bind
import org.koin.dsl.module
class AndroidMediaConverter(private val appContext: Context) : MediaConverter() {
override suspend fun convertAudioFile(
inputFilePath: String,
outputFilePath: String,
audioQuality: AudioQuality,
progressCallbacks: (Long) -> Unit,
) = executeSafelyInPool {
var progressing = true
var error = ""
var timeout = 600_000L * 2 // 20 min
val progressDelayCheck = 500L
// 192 is Default
val audioBitrate =
if (audioQuality == AudioQuality.UNKNOWN) 192 else audioQuality.kbps.toIntOrNull()
?: 192
FFmpeg.getInstance(appContext).execute(
arrayOf(
"-i",
inputFilePath,
"-y", /*"-acodec", "libmp3lame",*/
"-b:a",
"${audioBitrate}k",
"-vn",
outputFilePath
), object : ExecuteBinaryResponseHandler() {
override fun onSuccess(message: String?) {
//Log.d("FFmpeg Command", "Success $message")
progressing = false
}
override fun onProgress(message: String?) {
super.onProgress(message)
Log.d("FFmpeg Progress", "Progress $message --- $inputFilePath")
}
override fun onFailure(message: String?) {
error = "Failed: $message $inputFilePath"
error += "FFmpeg Support" + FFmpeg.getInstance(appContext).isSupported.toString()
Log.d("FFmpeg Error", error)
progressing = false
}
}
)
while (progressing) {
if (timeout < 0) throw SpotiFlyerException.MP3ConversionFailed("$error Conversion Timeout for $inputFilePath")
delay(progressDelayCheck)
timeout -= progressDelayCheck
}
if(error.isNotBlank()) throw SpotiFlyerException.MP3ConversionFailed(error)
// Return output file path after successful conversion
outputFilePath
}
}
internal actual fun mediaConverterModule() = module {
single { AndroidMediaConverter(get()) } bind MediaConverter::class
}

View File

@ -14,12 +14,13 @@
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.shabinder.common.di
package com.shabinder.common.core_components.media_converter
import android.util.Log
import com.mpatric.mp3agic.ID3v1Tag
import com.mpatric.mp3agic.ID3v24Tag
import com.mpatric.mp3agic.Mp3File
import com.shabinder.common.core_components.file_manager.downloadFile
import com.shabinder.common.models.DownloadResult
import com.shabinder.common.models.TrackDetails
import kotlinx.coroutines.flow.collect
@ -48,7 +49,7 @@ fun Mp3File.setId3v1Tags(track: TrackDetails): Mp3File {
}
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails, outputFilePath: String? = null) {
val id3v2Tag = ID3v24Tag().apply {
albumArtist = track.albumArtists.joinToString(", ")
artist = track.artists.joinToString(", ")
@ -71,7 +72,7 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
fis.close()
id3v2Tag.setAlbumImage(bytesArray, "image/jpeg")
this.id3v2Tag = id3v2Tag
saveFile(track.outputFilePath)
saveFile(outputFilePath ?: track.outputFilePath)
} catch (e: java.io.FileNotFoundException) {
Log.e("Error", "Couldn't Write Cached Mp3 Album Art, Downloading And Trying Again, error: ${e.message}")
try {
@ -83,7 +84,7 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
is DownloadResult.Success -> {
id3v2Tag.setAlbumImage(it.byteArray, "image/jpeg")
this.id3v2Tag = id3v2Tag
saveFile(track.outputFilePath)
saveFile(outputFilePath ?: track.outputFilePath)
}
is DownloadResult.Progress -> {} // Nothing for Now , no progress bar to show
}
@ -96,9 +97,11 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
}
fun Mp3File.saveFile(filePath: String) {
save(filePath.substringBeforeLast('.') + ".new.mp3")
val m4aFile = File(filePath)
m4aFile.delete()
val newFile = File((filePath.substringBeforeLast('.') + ".new.mp3"))
save(filePath.substringBeforeLast('.') + ".tagged.mp3")
val oldFile = File(filePath)
oldFile.delete()
val newFile = File((filePath.substringBeforeLast('.') + ".tagged.mp3"))
newFile.renameTo(File(filePath.substringBeforeLast('.') + ".mp3"))
}

View File

@ -20,9 +20,6 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.ImageBitmap
actual data class Picture(
var image: ImageBitmap?
)
fun getMemoryEfficientBitmap(
input: ByteArray,
reqWidth: Int,

View File

@ -0,0 +1,7 @@
package com.shabinder.common.core_components.picture
import androidx.compose.ui.graphics.ImageBitmap
actual data class Picture(
var image: ImageBitmap?
)

View File

@ -0,0 +1,25 @@
package com.shabinder.common.core_components
import co.touchlab.kermit.Kermit
import com.russhwolf.settings.Settings
import com.shabinder.common.core_components.analytics.analyticsModule
import com.shabinder.common.core_components.file_manager.fileManagerModule
import com.shabinder.common.core_components.media_converter.mediaConverterModule
import com.shabinder.common.core_components.preference_manager.PreferenceManager
import com.shabinder.common.core_components.utils.createHttpClient
import com.shabinder.common.database.getLogger
import org.koin.dsl.module
fun coreComponentModules(enableLogging: Boolean) = listOf(
commonModule(enableLogging),
analyticsModule(),
fileManagerModule(),
mediaConverterModule()
)
private fun commonModule(enableLogging: Boolean) = module {
single { createHttpClient(enableLogging) }
single { Settings() }
single { Kermit(getLogger()) }
single { PreferenceManager(get()) }
}

View File

@ -0,0 +1,30 @@
package com.shabinder.common.core_components.analytics
import org.koin.core.module.Module
interface AnalyticsManager {
fun init()
fun onStart()
fun onStop()
fun giveConsent()
fun isTracking(): Boolean
fun revokeConsent()
fun sendView(name: String, extras: MutableMap<String, Any> = mutableMapOf())
fun sendEvent(eventName: String, extras: MutableMap<String, Any> = mutableMapOf())
fun track(event: AnalyticsAction) = event.track(this)
fun sendCrashReport(error: Throwable, extras: MutableMap<String, Any> = mutableMapOf())
companion object {
abstract class AnalyticsAction {
abstract fun track(analyticsManager: AnalyticsManager)
}
}
}
@Suppress("ClassName", "SpellCheckingInspection")
object COUNTLY_CONFIG {
const val APP_KEY = "27820f304468cc651ef47d787f0cb5fe11c577df"
const val SERVER_URL = "https://counlty.shabinder.in"
}
internal expect fun analyticsModule(): Module

View File

@ -0,0 +1,9 @@
package com.shabinder.common.core_components.analytics
sealed class AnalyticsEvent(private val eventName: String, private val extras: MutableMap<String, Any> = mutableMapOf()): AnalyticsManager.Companion.AnalyticsAction() {
override fun track(analyticsManager: AnalyticsManager) = analyticsManager.sendEvent(eventName,extras)
object AppLaunch: AnalyticsEvent("app_launch")
object DonationDialogOpen: AnalyticsEvent("donation_dialog_open")
}

View File

@ -0,0 +1,10 @@
package com.shabinder.common.core_components.analytics
import com.shabinder.common.core_components.analytics.AnalyticsManager.Companion.AnalyticsAction
sealed class AnalyticsView(private val viewName: String, private val extras: MutableMap<String, Any> = mutableMapOf()) : AnalyticsAction() {
override fun track(analyticsManager: AnalyticsManager) = analyticsManager.sendView(viewName,extras)
object HomeScreen: AnalyticsView("home_screen")
object ListScreen: AnalyticsView("list_screen")
}

View File

@ -14,44 +14,65 @@
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.shabinder.common.di
package com.shabinder.common.core_components.file_manager
import co.touchlab.kermit.Kermit
import com.shabinder.common.database.SpotiFlyerDatabase
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.di.utils.removeIllegalChars
import com.shabinder.common.core_components.media_converter.MediaConverter
import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.core_components.preference_manager.PreferenceManager
import com.shabinder.common.core_components.utils.createHttpClient
import com.shabinder.common.models.DownloadResult
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.utils.removeIllegalChars
import com.shabinder.database.Database
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import org.koin.core.module.Module
import kotlin.math.roundToInt
expect class Dir(
logger: Kermit,
preferenceManager: PreferenceManager,
spotiFlyerDatabase: SpotiFlyerDatabase,
) {
internal expect fun fileManagerModule(): Module
interface FileManager {
val logger: Kermit
val preferenceManager: PreferenceManager
val mediaConverter: MediaConverter
val db: Database?
fun isPresent(path: String): Boolean
fun fileSeparator(): String
fun defaultDir(): String
fun imageCacheDir(): String
fun createDirectory(dirPath: String)
suspend fun cacheImage(image: Any, path: String) // in Android = ImageBitmap, Desktop = BufferedImage
suspend fun loadImage(url: String, reqWidth: Int = 150, reqHeight: Int = 150): Picture
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 = {}
): SuspendableEvent<String, Throwable>
fun addToLibrary(path: String)
}
/*
* Call this function at startup!
* */
fun Dir.createDirectories() {
fun FileManager.createDirectories() {
try {
createDirectory(defaultDir())
createDirectory(imageCacheDir())
@ -59,12 +80,20 @@ fun Dir.createDirectories() {
createDirectory(defaultDir() + "Albums/")
createDirectory(defaultDir() + "Playlists/")
createDirectory(defaultDir() + "YT_Downloads/")
} catch (e: Exception) {}
} catch (ignored: Exception) {}
}
fun Dir.finalOutputDir(itemName: String, type: String, subFolder: String, defaultDir: String, extension: String = ".mp3"): String =
fun FileManager.finalOutputDir(
itemName: String,
type: String,
subFolder: String,
defaultDir: String,
extension: String = ".mp3"
): String =
defaultDir + removeIllegalChars(type) + this.fileSeparator() +
if (subFolder.isEmpty())"" else { removeIllegalChars(subFolder) + this.fileSeparator() } +
if (subFolder.isEmpty()) "" else {
removeIllegalChars(subFolder) + this.fileSeparator()
} +
removeIllegalChars(itemName) + extension
/*DIR Specific Operation End*/
@ -74,7 +103,6 @@ fun getNameURL(url: String): String {
suspend fun downloadFile(url: String): Flow<DownloadResult> {
return flow {
try {
val client = createHttpClient()
val response = client.get<HttpStatement>(url).execute()
val data = ByteArray(response.contentLength()!!.toInt())
@ -92,11 +120,10 @@ suspend fun downloadFile(url: String): Flow<DownloadResult> {
emit(DownloadResult.Error("File not downloaded"))
}
client.close()
} catch (e: Exception) {
}.catch { e ->
e.printStackTrace()
emit(DownloadResult.Error(e.message ?: "File not downloaded"))
}
}
}
suspend fun downloadByteArray(

View File

@ -0,0 +1,28 @@
package com.shabinder.common.core_components.media_converter
import com.shabinder.common.core_components.parallel_executor.ParallelExecutor
import com.shabinder.common.core_components.parallel_executor.ParallelProcessor
import com.shabinder.common.models.AudioQuality
import com.shabinder.common.models.dispatcherDefault
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import org.koin.core.module.Module
abstract class MediaConverter : ParallelProcessor {
/*
* Operations Pool
* */
override val parallelExecutor = ParallelExecutor(dispatcherDefault)
/*
* By Default AudioQuality Output will be equal to Input's Quality,i.e, Denoted by AudioQuality.UNKNOWN
* */
abstract suspend fun convertAudioFile(
inputFilePath: String,
outputFilePath: String,
audioQuality: AudioQuality = AudioQuality.UNKNOWN,
progressCallbacks: (Long) -> Unit = {},
): SuspendableEvent<String, Throwable>
}
internal expect fun mediaConverterModule(): Module

View File

@ -14,40 +14,62 @@
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.shabinder.common.di.utils
package com.shabinder.common.core_components.parallel_executor
// Dependencies:
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9-native-mt")
// implementation("org.jetbrains.kotlinx:atomicfu:0.14.4")
// Gist: https://gist.github.com/fluidsonic/ba32de21c156bbe8424c8d5fc20dcd8e
import com.shabinder.common.di.dispatcherIO
import com.shabinder.common.models.dispatcherIO
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import io.ktor.utils.io.core.*
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
class ParallelExecutor(
parentContext: CoroutineContext = dispatcherIO,
) : Closeable {
interface ParallelProcessor {
private val concurrentOperationLimit = atomic(4)
private val coroutineContext = parentContext + Job()
val parallelExecutor: ParallelExecutor
suspend fun <T> executeSafelyInPool(block: suspend () -> T): SuspendableEvent<T, Throwable> {
return SuspendableEvent {
parallelExecutor.executeSuspending(block)
}
}
suspend fun <T> executeSafelyInPool(
onComplete: suspend (result: SuspendableEvent<T, Throwable>) -> Unit = {},
block: suspend () -> T
): SuspendableEvent<T, Throwable> {
return SuspendableEvent {
parallelExecutor.executeSuspending(block)
}.also { onComplete(it) }
}
suspend fun stopAllTasks() {
parallelExecutor.closeAndReInit()
}
}
class ParallelExecutor(
private val context: CoroutineContext = dispatcherIO,
concurrentOperationLimit: Int = 4
) : Closeable, CoroutineScope {
private var service: Job = SupervisorJob()
override val coroutineContext get() = context + service
private var isClosed = atomic(false)
private val killQueue = Channel<Unit>(Channel.UNLIMITED)
private val operationQueue = Channel<Operation<*>>(Channel.RENDEZVOUS)
private var killQueue = Channel<Unit>(Channel.UNLIMITED)
private var operationQueue = Channel<Operation<*>>(Channel.RENDEZVOUS)
private var concurrentOperationLimit = atomic(concurrentOperationLimit)
init {
startOrStopProcessors(expectedCount = concurrentOperationLimit.value, actualCount = 0)
startOrStopProcessors(expectedCount = this.concurrentOperationLimit.value, actualCount = 0)
}
override fun close() {
@ -58,9 +80,29 @@ class ParallelExecutor(
killQueue.close(cause)
operationQueue.close(cause)
service.cancel(cause)
coroutineContext.cancel(cause)
}
fun reviveIfClosed() {
if (!service.isActive) {
closeAndReInit()
}
}
fun closeAndReInit(newConcurrentOperationLimit: Int = 4) {
// Close Everything
close()
// ReInit everything
service = SupervisorJob()
isClosed = atomic(false)
killQueue = Channel(Channel.UNLIMITED)
operationQueue = Channel(Channel.RENDEZVOUS)
concurrentOperationLimit = atomic(newConcurrentOperationLimit)
startOrStopProcessors(expectedCount = this.concurrentOperationLimit.value, actualCount = 0)
}
private fun CoroutineScope.launchProcessor() = launch {
while (true) {
val operation = select<Operation<*>?> {
@ -72,7 +114,7 @@ class ParallelExecutor(
}
}
suspend fun <Result> execute(block: suspend () -> Result): Result =
suspend fun <Result> executeSuspending(block: suspend () -> Result): Result =
withContext(coroutineContext) {
val operation = Operation(block)
operationQueue.send(operation)
@ -80,6 +122,15 @@ class ParallelExecutor(
operation.result.await()
}
fun <Result> execute(onComplete: (Result) -> Unit = {}, block: suspend () -> Result) {
launch(coroutineContext) {
val operation = Operation(block)
operationQueue.send(operation)
onComplete(operation.result.await())
}
}
// TODO This launches all coroutines in advance even if they're never needed. Find a lazy way to do this.
fun setConcurrentOperationLimit(limit: Int) {
require(limit >= 1) { "'limit' must be greater than zero: $limit" }
@ -89,6 +140,7 @@ class ParallelExecutor(
}
private fun startOrStopProcessors(expectedCount: Int, actualCount: Int) {
if (!service.isActive) service = SupervisorJob()
if (expectedCount == actualCount)
return
@ -100,9 +152,7 @@ class ParallelExecutor(
change -= 1
if (change > 0)
with(CoroutineScope(coroutineContext)) {
repeat(change) { launchProcessor() }
}
else
repeat(-change) { killQueue.trySend(Unit).isSuccess }
}

View File

@ -0,0 +1,3 @@
package com.shabinder.common.core_components.picture
expect class Picture

View File

@ -1,9 +1,12 @@
package com.shabinder.common.di.preference
package com.shabinder.common.core_components.preference_manager
import com.russhwolf.settings.Settings
import com.shabinder.common.core_components.analytics.AnalyticsManager
import com.shabinder.common.models.AudioQuality
class PreferenceManager(settings: Settings) : Settings by settings {
class PreferenceManager(
settings: Settings,
) : Settings by settings {
companion object {
const val DIR_KEY = "downloadDir"
@ -13,9 +16,16 @@ class PreferenceManager(settings: Settings) : Settings by settings {
const val PREFERRED_AUDIO_QUALITY = "preferredAudioQuality"
}
lateinit var analyticsManager: AnalyticsManager
/* ANALYTICS */
val isAnalyticsEnabled get() = getBooleanOrNull(ANALYTICS_KEY) ?: false
fun toggleAnalytics(enabled: Boolean) = putBoolean(ANALYTICS_KEY, enabled)
fun toggleAnalytics(enabled: Boolean) {
putBoolean(ANALYTICS_KEY, enabled)
if (this::analyticsManager.isInitialized) {
if (enabled) analyticsManager.giveConsent() else analyticsManager.revokeConsent()
}
}
/* DOWNLOAD DIRECTORY */
val downloadDir get() = getStringOrNull(DIR_KEY)

View File

@ -0,0 +1,57 @@
package com.shabinder.common.core_components.utils
import com.shabinder.common.models.dispatcherIO
import com.shabinder.common.utils.globalJson
import io.ktor.client.*
import io.ktor.client.features.*
import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.*
import io.ktor.client.features.logging.*
import io.ktor.client.request.*
import kotlinx.coroutines.withContext
import kotlin.native.concurrent.SharedImmutable
suspend fun isInternetAccessible(): Boolean {
return withContext(dispatcherIO) {
try {
ktorHttpClient.head<String>("https://open.spotify.com/")
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
}
fun createHttpClient(enableNetworkLogs: Boolean = false) = HttpClient {
// https://github.com/Kotlin/kotlinx.serialization/issues/1450
install(JsonFeature) {
serializer = KotlinxSerializer(globalJson)
}
install(HttpTimeout) {
socketTimeoutMillis = 520_000
requestTimeoutMillis = 360_000
connectTimeoutMillis = 360_000
}
// WorkAround for Freezing
// Use httpClient.getData / httpClient.postData Extensions
/*install(JsonFeature) {
serializer = KotlinxSerializer(
Json {
isLenient = true
ignoreUnknownKeys = true
}
)
}*/
if (enableNetworkLogs) {
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.INFO
}
}
}
/*Client Active Throughout App's Lifetime*/
@SharedImmutable
val ktorHttpClient = HttpClient {}

View File

@ -1,4 +1,4 @@
package com.shabinder.common.di.utils
package com.shabinder.common.core_components.utils
import com.arkivanov.decompose.value.Value
import com.arkivanov.decompose.value.ValueObserver

View File

@ -0,0 +1,82 @@
package com.shabinder.common.core_components.analytics
import com.shabinder.common.core_components.file_manager.FileManager
import ly.count.sdk.java.Config
import ly.count.sdk.java.Config.DeviceIdStrategy
import ly.count.sdk.java.Config.Feature
import ly.count.sdk.java.ConfigCore.LoggingLevel
import ly.count.sdk.java.Countly
import org.koin.dsl.bind
import org.koin.dsl.module
import java.io.File
internal class DesktopAnalyticsManager(
private val fileManager: FileManager
) : AnalyticsManager {
init {
init()
}
override fun init() {
val config: Config = Config(COUNTLY_CONFIG.SERVER_URL, COUNTLY_CONFIG.APP_KEY).apply {
eventsBufferSize = 2
loggingLevel = LoggingLevel.DEBUG
setDeviceIdStrategy(DeviceIdStrategy.UUID)
enableFeatures(*featuresSet)
setRequiresConsent(true)
}
Countly.init(File(fileManager.defaultDir()), config)
Countly.session().begin();
}
override fun giveConsent() {
Countly.onConsent(*featuresSet)
}
override fun isTracking(): Boolean = Countly.isTracking(Feature.Events)
override fun revokeConsent() {
Countly.onConsentRemoval(*featuresSet)
}
override fun sendView(name: String, extras: MutableMap<String, Any>) {
Countly.api().view(name)
}
override fun sendEvent(eventName: String, extras: MutableMap<String, Any>) {
Countly.api().event(eventName)
.setSegmentation(extras.filterValues { it is String } as? MutableMap<String, String> ?: emptyMap()).record()
}
override fun sendCrashReport(error: Throwable, extras: MutableMap<String, Any>) {
Countly.api().addCrashReport(
error,
extras.getOrDefault("fatal", true) as Boolean,
error.javaClass.simpleName,
extras.filterValues { it is String } as? MutableMap<String, String> ?: emptyMap()
)
}
companion object {
val featuresSet = arrayOf(
Feature.Events,
Feature.Sessions,
Feature.CrashReporting,
Feature.Views,
Feature.UserProfiles,
Feature.Location,
)
}
override fun onStart() {}
override fun onStop() {}
}
actual fun analyticsModule() = module {
single { DesktopAnalyticsManager(get()) } bind AnalyticsManager::class
}

View File

@ -14,21 +14,35 @@
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.shabinder.common.di
package com.shabinder.common.core_components.file_manager
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import co.touchlab.kermit.Kermit
import com.github.kokorin.jaffree.JaffreeException
import com.mpatric.mp3agic.InvalidDataException
import com.mpatric.mp3agic.Mp3File
import com.shabinder.common.core_components.media_converter.MediaConverter
import com.shabinder.common.core_components.parallel_executor.ParallelExecutor
import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.core_components.preference_manager.PreferenceManager
import com.shabinder.common.core_components.removeAllTags
import com.shabinder.common.core_components.setId3v1Tags
import com.shabinder.common.core_components.setId3v2TagsAndSaveFile
import com.shabinder.common.database.SpotiFlyerDatabase
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.dispatcherIO
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.event.coroutines.failure
import com.shabinder.common.models.event.coroutines.map
import com.shabinder.common.models.methods
import com.shabinder.database.Database
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import org.jetbrains.skija.Image
import org.koin.dsl.bind
import org.koin.dsl.module
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.io.File
@ -38,33 +52,45 @@ import java.net.HttpURLConnection
import java.net.URL
import javax.imageio.ImageIO
actual class Dir actual constructor(
private val logger: Kermit,
private val preferenceManager: PreferenceManager,
internal actual fun fileManagerModule() = module {
single { DesktopFileManager(get(), get(), get(), get()) } bind FileManager::class
}
val DownloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>> = MutableSharedFlow(1)
// Scope Allowing 4 Parallel Downloads
val DownloadScope = ParallelExecutor(Dispatchers.IO)
class DesktopFileManager(
override val logger: Kermit,
override val preferenceManager: PreferenceManager,
override val mediaConverter: MediaConverter,
spotiFlyerDatabase: SpotiFlyerDatabase,
) {
) : FileManager {
init {
createDirectories()
}
actual fun fileSeparator(): String = File.separator
override fun fileSeparator(): String = File.separator
actual fun imageCacheDir(): String = System.getProperty("user.home") +
override fun imageCacheDir(): String = System.getProperty("user.home") +
fileSeparator() + "SpotiFlyer/.images" + fileSeparator()
private val defaultBaseDir = System.getProperty("user.home")
actual fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) + fileSeparator() +
override fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) + fileSeparator() +
"SpotiFlyer" + fileSeparator()
actual fun isPresent(path: String): Boolean = File(path).exists()
override fun isPresent(path: String): Boolean = File(path).exists()
actual fun createDirectory(dirPath: String) {
override fun createDirectory(dirPath: String) {
val yourAppDir = File(dirPath)
if (!yourAppDir.exists() && !yourAppDir.isDirectory) { // create empty directory
if (yourAppDir.mkdirs()) { logger.i { "$dirPath created" } } else {
if (yourAppDir.mkdirs()) {
logger.i { "$dirPath created" }
} else {
logger.e { "Unable to create Dir: $dirPath!" }
}
} else {
@ -72,11 +98,11 @@ actual class Dir actual constructor(
}
}
actual suspend fun clearCache() {
override suspend fun clearCache() {
File(imageCacheDir()).deleteRecursively()
}
actual suspend fun cacheImage(image: Any, path: String) {
override suspend fun cacheImage(image: Any, path: String): Unit = withContext(dispatcherIO) {
try {
(image as? BufferedImage)?.let {
ImageIO.write(it, "jpeg", File(path))
@ -87,11 +113,11 @@ actual class Dir actual constructor(
}
@Suppress("BlockingMethodInNonBlockingContext")
actual suspend fun saveFileWithMetadata(
override suspend fun saveFileWithMetadata(
mp3ByteArray: ByteArray,
trackDetails: TrackDetails,
postProcess: (track: TrackDetails) -> Unit
) {
) = withContext(dispatcherIO) {
val songFile = File(trackDetails.outputFilePath)
try {
/*
@ -103,61 +129,52 @@ actual class Dir actual constructor(
}
if (mp3ByteArray.isNotEmpty()) songFile.writeBytes(mp3ByteArray)
when (trackDetails.outputFilePath.substringAfterLast('.')) {
".mp3" -> {
Mp3File(File(songFile.absolutePath))
.removeAllTags()
.setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails)
addToLibrary(songFile.absolutePath)
}
".m4a" -> {
/*FFmpeg.executeAsync(
"-i ${m4aFile.absolutePath} -y -b:a 160k -acodec libmp3lame -vn ${m4aFile.absolutePath.substringBeforeLast('.') + ".mp3"}"
){ _, returnCode ->
when (returnCode) {
Config.RETURN_CODE_SUCCESS -> {
//FFMPEG task Completed
logger.d{ "Async command execution completed successfully." }
scope.launch {
Mp3File(File(m4aFile.absolutePath.substringBeforeLast('.') + ".mp3"))
.removeAllTags()
.setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails)
addToLibrary(m4aFile.absolutePath.substringBeforeLast('.') + ".mp3")
}
}
Config.RETURN_CODE_CANCEL -> {
logger.d{"Async command execution cancelled by user."}
}
else -> {
logger.d { "Async command execution failed with rc=$returnCode" }
}
}
}*/
}
else -> {
try {
// Add Mp3 Tags and Add to Library
Mp3File(File(songFile.absolutePath))
.removeAllTags()
.setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails)
addToLibrary(songFile.absolutePath)
} catch (e: Exception) { e.printStackTrace() }
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
// Toast.makeText(appContext,"Could Not Create File:\n${songFile.absolutePath}",Toast.LENGTH_SHORT).show()
// Media File Isn't MP3 lets Convert It first
if (e is InvalidDataException) {
val convertedFilePath = songFile.absolutePath.substringBeforeLast('.') + ".temp.mp3"
val conversionResult = mediaConverter.convertAudioFile(
inputFilePath = songFile.absolutePath,
outputFilePath = convertedFilePath,
trackDetails.audioQuality
)
conversionResult.map { outputFilePath ->
Mp3File(File(outputFilePath))
.removeAllTags()
.setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails, trackDetails.outputFilePath)
addToLibrary(trackDetails.outputFilePath)
}.fold(
success = {},
failure = {
throw it
}
)
File(convertedFilePath).delete()
} else throw e
}
SuspendableEvent.success(trackDetails.outputFilePath)
} catch (e: Throwable) {
if(e is JaffreeException) methods.value.showPopUpMessage("No FFmpeg found at path.")
if (songFile.exists()) songFile.delete()
logger.e { "${songFile.absolutePath} could not be created" }
SuspendableEvent.error(e)
}
}
actual fun addToLibrary(path: String) {}
actual suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture {
override fun addToLibrary(path: String) {}
override suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture {
val cachePath = imageCacheDir() + getNameURL(url)
var picture: ImageBitmap? = loadCachedImage(cachePath, reqWidth, reqHeight)
if (picture == null) picture = freshImage(url, reqWidth, reqHeight)
@ -173,6 +190,7 @@ actual class Dir actual constructor(
}
}
@OptIn(DelicateCoroutinesApi::class)
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun freshImage(url: String, reqWidth: Int, reqHeight: Int): ImageBitmap? {
return withContext(Dispatchers.IO) {
@ -198,7 +216,7 @@ actual class Dir actual constructor(
}
}
actual val db: Database? = spotiFlyerDatabase.instance
override val db: Database? = spotiFlyerDatabase.instance
}
fun BufferedImage.toImageBitmap() = Image.makeFromEncoded(

View File

@ -0,0 +1,43 @@
package com.shabinder.common.core_components.media_converter
import com.github.kokorin.jaffree.ffmpeg.FFmpeg
import com.github.kokorin.jaffree.ffmpeg.UrlInput
import com.github.kokorin.jaffree.ffmpeg.UrlOutput
import com.shabinder.common.models.AudioQuality
import org.koin.dsl.bind
import org.koin.dsl.module
import kotlin.io.path.Path
class DesktopMediaConverter : MediaConverter() {
override suspend fun convertAudioFile(
inputFilePath: String,
outputFilePath: String,
audioQuality: AudioQuality,
progressCallbacks: (Long) -> Unit,
) = executeSafelyInPool {
val audioBitrate =
if (audioQuality == AudioQuality.UNKNOWN) 192 else audioQuality.kbps.toIntOrNull()
?: 192
FFmpeg.atPath().run {
addInput(UrlInput.fromUrl(inputFilePath))
setOverwriteOutput(true)
if (audioQuality != AudioQuality.UNKNOWN) {
addArguments("-b:a", "${audioBitrate}k")
}
addArguments("-acodec", "libmp3lame")
addArgument("-vn")
addOutput(UrlOutput.toUrl(outputFilePath))
setProgressListener {
progressCallbacks(it.timeMillis)
}
execute()
return@run outputFilePath
}
}
}
internal actual fun mediaConverterModule() = module {
single { DesktopMediaConverter() } bind MediaConverter::class
}

View File

@ -14,11 +14,12 @@
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.shabinder.common.di
package com.shabinder.common.core_components
import com.mpatric.mp3agic.ID3v1Tag
import com.mpatric.mp3agic.ID3v24Tag
import com.mpatric.mp3agic.Mp3File
import com.shabinder.common.core_components.file_manager.downloadFile
import com.shabinder.common.models.DownloadResult
import com.shabinder.common.models.TrackDetails
import kotlinx.coroutines.flow.collect
@ -47,16 +48,22 @@ fun Mp3File.setId3v1Tags(track: TrackDetails): Mp3File {
return this
}
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails, outputFilePath: String? = null) {
val id3v2Tag = ID3v24Tag().apply {
artist = track.artists.joinToString(",")
albumArtist = track.albumArtists.joinToString(", ")
artist = track.artists.joinToString(", ")
title = track.title
album = track.albumName
year = track.year
comment = "Genres:${track.comment}"
lyrics = "Gonna Implement Soon"
genreDescription = "Genre: " + track.genre.joinToString(", ")
comment = track.comment
lyrics = track.lyrics ?: ""
url = track.trackUrl
if (track.trackNumber != null)
this.track = track.trackNumber.toString()
}
try {
val art = File(track.albumArtPath)
@ -66,7 +73,7 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
fis.close()
id3v2Tag.setAlbumImage(bytesArray, "image/jpeg")
this.id3v2Tag = id3v2Tag
saveFile(track.outputFilePath)
saveFile(outputFilePath ?: track.outputFilePath)
} catch (e: java.io.FileNotFoundException) {
try {
// Image Still Not Downloaded!
@ -77,21 +84,23 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
is DownloadResult.Success -> {
id3v2Tag.setAlbumImage(it.byteArray, "image/jpeg")
this.id3v2Tag = id3v2Tag
saveFile(track.outputFilePath)
saveFile(outputFilePath ?: track.outputFilePath)
}
is DownloadResult.Progress -> {} // Nothing for Now , no progress bar to show
}
}
} catch (e: Exception) {
// log("Error", "Couldn't Write Mp3 Album Art, error: ${e.stackTrace}")
e.printStackTrace()
}
}
}
fun Mp3File.saveFile(filePath: String) {
save(filePath.substringBeforeLast('.') + ".new.mp3")
val m4aFile = File(filePath)
m4aFile.delete()
val newFile = File((filePath.substringBeforeLast('.') + ".new.mp3"))
save(filePath.substringBeforeLast('.') + ".tagged.mp3")
val oldFile = File(filePath)
oldFile.delete()
val newFile = File((filePath.substringBeforeLast('.') + ".tagged.mp3"))
newFile.renameTo(File(filePath.substringBeforeLast('.') + ".mp3"))
}

View File

@ -0,0 +1,7 @@
package com.shabinder.common.core_components.picture
import androidx.compose.ui.graphics.ImageBitmap
actual data class Picture(
var image: ImageBitmap?
)

View File

@ -17,7 +17,7 @@
@file:JsModule("file-saver")
@file:JsNonModule
package com.shabinder.common.di
package com.shabinder.common.core_components
import org.w3c.files.Blob

View File

@ -14,7 +14,7 @@
* * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.shabinder.common.di
package com.shabinder.common.core_components
import org.khronos.webgl.ArrayBuffer
import org.w3c.files.Blob

View File

@ -0,0 +1,30 @@
package com.shabinder.common.core_components.analytics
import org.koin.dsl.bind
import org.koin.dsl.module
// TODO("Not yet implemented")
private val webAnalytics =
object : AnalyticsManager {
override fun init() {}
override fun onStart() {}
override fun onStop() {}
override fun giveConsent() {}
override fun isTracking(): Boolean = false
override fun revokeConsent() {}
override fun sendView(name: String, extras: MutableMap<String, Any>) {}
override fun sendEvent(eventName: String, extras: MutableMap<String, Any>) {}
override fun sendCrashReport(error: Throwable, extras: MutableMap<String, Any>) {}
}
actual fun analyticsModule() = module {
single { webAnalytics } bind AnalyticsManager::class
}

View File

@ -0,0 +1,149 @@
/*
* * 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.core_components.file_manager
import co.touchlab.kermit.Kermit
import com.shabinder.common.core_components.ID3Writer
import com.shabinder.common.core_components.media_converter.MediaConverter
import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.core_components.preference_manager.PreferenceManager
import com.shabinder.common.core_components.saveAs
import com.shabinder.common.database.SpotiFlyerDatabase
import com.shabinder.common.models.DownloadResult
import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.corsApi
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.utils.removeIllegalChars
import com.shabinder.database.Database
import kotlinext.js.Object
import kotlinext.js.js
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collect
import org.khronos.webgl.ArrayBuffer
import org.khronos.webgl.Int8Array
import org.koin.dsl.bind
import org.koin.dsl.module
import org.w3c.dom.ImageBitmap
internal actual fun fileManagerModule() = module {
single { WebFileManager(get(), get(), get(), get()) } bind FileManager::class
}
class WebFileManager(
override val logger: Kermit,
override val preferenceManager: PreferenceManager,
override val mediaConverter: MediaConverter,
spotiFlyerDatabase: SpotiFlyerDatabase,
) : FileManager {
/*init {
createDirectories()
}*/
/*
* TODO
* */
override fun fileSeparator(): String = "/"
override fun imageCacheDir(): String = "TODO" +
fileSeparator() + "SpotiFlyer/.images" + fileSeparator()
override fun defaultDir(): String = "TODO" + fileSeparator() +
"SpotiFlyer" + fileSeparator()
override fun isPresent(path: String): Boolean = false
override fun createDirectory(dirPath: String) {}
override suspend fun clearCache() {}
override suspend fun cacheImage(image: Any, path: String) {}
@Suppress("BlockingMethodInNonBlockingContext")
override suspend fun saveFileWithMetadata(
mp3ByteArray: ByteArray,
trackDetails: TrackDetails,
postProcess: (track: TrackDetails) -> Unit
): SuspendableEvent<String, Throwable> {
return SuspendableEvent {
val writer = ID3Writer(mp3ByteArray.toArrayBuffer())
val albumArt = downloadFile(corsApi + trackDetails.albumArtURL)
albumArt.collect {
when (it) {
is DownloadResult.Success -> {
logger.d { "Album Art Downloaded Success" }
val albumArtObj = js {
this["type"] = 3
this["data"] = it.byteArray.toArrayBuffer()
this["description"] = "Cover Art"
}
writeTagsAndSave(writer, albumArtObj as Object, trackDetails)
}
is DownloadResult.Error -> {
logger.d { "Album Art Downloading Error" }
writeTagsAndSave(writer, null, trackDetails)
}
is DownloadResult.Progress -> logger.d { "Album Art Downloading: ${it.progress}" }
}
}
trackDetails.outputFilePath
}
}
private suspend fun writeTagsAndSave(writer: ID3Writer, albumArt: Object?, trackDetails: TrackDetails) {
writer.apply {
setFrame("TIT2", trackDetails.title)
setFrame("TPE1", trackDetails.artists.toTypedArray())
setFrame("TALB", trackDetails.albumName ?: "")
try {
trackDetails.year?.substring(0, 4)?.toInt()?.let { setFrame("TYER", it) }
} catch (e: Exception) {
}
setFrame("TPE2", trackDetails.artists.joinToString(","))
setFrame("WOAS", trackDetails.source.toString())
setFrame("TLEN", trackDetails.durationSec)
albumArt?.let { setFrame("APIC", it) }
}
writer.addTag()
allTracksStatus[trackDetails.title] = DownloadStatus.Downloaded
DownloadProgressFlow.emit(allTracksStatus)
saveAs(writer.getBlob(), "${removeIllegalChars(trackDetails.title)}.mp3")
}
override fun addToLibrary(path: String) {}
override suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture {
return Picture(url)
}
private fun loadCachedImage(cachePath: String): ImageBitmap? = null
private suspend fun freshImage(url: String): ImageBitmap? = null
override val db: Database? = spotiFlyerDatabase.instance
}
fun ByteArray.toArrayBuffer(): ArrayBuffer {
return this.unsafeCast<Int8Array>().buffer
}
val DownloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>> = MutableSharedFlow(1)
// Error:https://github.com/Kotlin/kotlinx.atomicfu/issues/182
// val DownloadScope = ParallelExecutor(Dispatchers.Default) //Download Pool of 4 parallel
val allTracksStatus: HashMap<String, DownloadStatus> = hashMapOf()

View File

@ -0,0 +1,23 @@
package com.shabinder.common.core_components.media_converter
import com.shabinder.common.models.AudioQuality
import com.shabinder.common.models.event.Event
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import org.koin.dsl.bind
import org.koin.dsl.module
class WebMediaConverter: MediaConverter() {
override suspend fun convertAudioFile(
inputFilePath: String,
outputFilePath: String,
audioQuality: AudioQuality,
progressCallbacks: (Long) -> Unit
): SuspendableEvent<String, Throwable> {
// TODO("Not yet implemented")
return SuspendableEvent.error(NotImplementedError())
}
}
internal actual fun mediaConverterModule() = module {
single { WebMediaConverter() } bind MediaConverter::class
}

View File

@ -0,0 +1,5 @@
package com.shabinder.common.core_components.picture
actual data class Picture(
var imageUrl: String
)

View File

@ -57,8 +57,5 @@ kotlin {
api(Internationalization.dep)
}
}
androidMain {
dependencies {}
}
}
}

View File

@ -0,0 +1,7 @@
package com.shabinder.common.models
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
// IO-Dispatcher
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO

View File

@ -1,3 +0,0 @@
package com.shabinder.common
fun <T : Any?> T?.requireNotNull(): T = requireNotNull(this)

View File

@ -5,7 +5,8 @@ enum class AudioQuality(val kbps: String) {
KBPS160("160"),
KBPS192("192"),
KBPS256("256"),
KBPS320("320");
KBPS320("320"),
UNKNOWN("-1");
companion object {
fun getQuality(kbps: String): AudioQuality {
@ -15,6 +16,7 @@ enum class AudioQuality(val kbps: String) {
"192" -> KBPS192
"256" -> KBPS256
"320" -> KBPS320
"-1" -> UNKNOWN
else -> KBPS160 // Use 160 as baseline
}
}

View File

@ -0,0 +1,10 @@
package com.shabinder.common.models
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
// IO-Dispatcher
expect val dispatcherIO: CoroutineDispatcher
// Default-Dispatcher
val dispatcherDefault: CoroutineDispatcher = Dispatchers.Default

View File

@ -41,9 +41,12 @@ data class TrackDetails(
val progress: Int = 2,
val downloadLink: String? = null,
val downloaded: DownloadStatus = DownloadStatus.NotDownloaded,
var audioQuality: AudioQuality = AudioQuality.KBPS192,
var outputFilePath: String, // UriString in Android
var videoID: String? = null,
) : Parcelable
) : Parcelable {
val outputMp3Path get() = outputFilePath.substringBeforeLast(".") + ".mp3"
}
@Serializable
sealed class DownloadStatus : Parcelable {

View File

@ -9,7 +9,7 @@ sealed class SpotiFlyerException(override val message: String) : Exception(messa
data class MP3ConversionFailed(
val extraInfo: String? = null,
override val message: String = "${Strings.mp3ConverterBusy()} \nCAUSE:$extraInfo"
override val message: String = /*${Strings.mp3ConverterBusy()} */"CAUSE:$extraInfo"
) : SpotiFlyerException(message)
data class UnknownReason(
@ -28,13 +28,17 @@ sealed class SpotiFlyerException(override val message: String) : Exception(messa
) : SpotiFlyerException(message)
data class DownloadLinkFetchFailed(
val trackName: String,
val jioSaavnError: Throwable,
val ytMusicError: Throwable,
override val message: String = "${Strings.noLinkFound()}: $trackName," +
val errorTrace: String
) : SpotiFlyerException(errorTrace) {
constructor(
trackName: String,
jioSaavnError: Throwable,
ytMusicError: Throwable,
errorTrace: String = "${Strings.noLinkFound()}: $trackName," +
" \n YtMusic Error's StackTrace: ${ytMusicError.stackTraceToString()} \n " +
" \n JioSaavn Error's StackTrace: ${jioSaavnError.stackTraceToString()} \n "
) : SpotiFlyerException(message)
): this(errorTrace)
}
data class LinkInvalid(
val link: String? = null,

View File

@ -155,6 +155,8 @@ sealed class SuspendableEvent<out V : Any?, out E : Throwable> : ReadOnlyPropert
// Factory methods
fun <E : Throwable> error(ex: E) = Failure<Nothing, E>(ex)
fun <V : Any> success(res: V) = Success<V, Throwable>(res)
inline fun <V : Any?> of(value: V?, crossinline fail: (() -> Throwable) = { Throwable() }): SuspendableEvent<V, Throwable> {
return value?.let { Success<V, Nothing>(it) } ?: error(fail())
}

View File

@ -1,5 +1,6 @@
package com.shabinder.common.models.saavn
import com.shabinder.common.models.AudioQuality
import com.shabinder.common.models.DownloadStatus
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
@ -43,4 +44,6 @@ data class SaavnSong @OptIn(ExperimentalSerializationApi::class) constructor(
val vlink: String? = null,
val year: String,
var downloaded: DownloadStatus = DownloadStatus.NotDownloaded
)
) {
val audioQuality get() = if (is320Kbps) AudioQuality.KBPS320 else AudioQuality.KBPS160
}

View File

@ -0,0 +1,26 @@
package com.shabinder.common.utils
import com.shabinder.common.models.TrackDetails
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
fun <T : Any?> T?.requireNotNull(): T = requireNotNull(this)
@OptIn(ExperimentalContracts::class)
inline fun buildString(track: TrackDetails, builderAction: StringBuilder.() -> Unit): String {
contract { callsInPlace(builderAction, InvocationKind.EXACTLY_ONCE) }
return StringBuilder().run {
appendLine("Find Link for ${track.title} ${if (!track.videoID.isNullOrBlank()) "-> VideoID:" + track.videoID else ""}")
apply(builderAction)
}.toString()
}
fun StringBuilder.appendPadded(data: Any?) {
appendLine().append(data).appendLine()
}
fun StringBuilder.appendPadded(header: Any?, data: Any?) {
appendLine().append(header).appendLine(data).appendLine()
}

View File

@ -1,4 +1,4 @@
package com.shabinder.common.di.utils
package com.shabinder.common.utils
/*
* JSON UTILS

View File

@ -1,20 +1,4 @@
/*
* * 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.di.utils
package com.shabinder.common.utils
import io.github.shabinder.TargetPlatforms
import io.github.shabinder.activePlatform
@ -22,7 +6,7 @@ import kotlinx.serialization.json.Json
import kotlin.native.concurrent.ThreadLocal
@ThreadLocal
val json by lazy {
val globalJson by lazy {
Json {
isLenient = true
ignoreUnknownKeys = true

View File

@ -0,0 +1,7 @@
package com.shabinder.common.models
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
// IO-Dispatcher
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO

View File

@ -1,4 +1,5 @@
package com.shabinder.common.models
actual interface PlatformActions
actual val StubPlatformActions = object : PlatformActions {}

View File

@ -0,0 +1,6 @@
package com.shabinder.common.models
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
actual val dispatcherIO: CoroutineDispatcher = Dispatchers.Default

View File

@ -30,31 +30,8 @@ kotlin {
dependencies {
implementation(project(":common:data-models"))
implementation(project(":common:database"))
implementation("org.jetbrains.kotlinx:atomicfu:0.16.2")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.2.1")
api(MultiPlatformSettings.dep)
implementation(Extras.youtubeDownloader)
implementation(Extras.fuzzyWuzzy)
implementation(MVIKotlin.rx)
}
}
androidMain {
dependencies {
implementation(compose.materialIconsExtended)
implementation(Extras.mp3agic)
// implementation(files("$rootDir/libs/mobile-ffmpeg.aar"))
}
}
desktopMain {
dependencies {
implementation(compose.materialIconsExtended)
implementation(Extras.mp3agic)
}
}
jsMain {
dependencies {
implementation(npm("browser-id3-writer", "4.4.0"))
implementation(npm("file-saver", "2.0.4"))
implementation(project(":common:providers"))
implementation(project(":common:core-components"))
}
}
}

View File

@ -1,205 +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.di
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Environment
import androidx.compose.ui.graphics.asImageBitmap
import co.touchlab.kermit.Kermit
import com.mpatric.mp3agic.Mp3File
import com.shabinder.common.database.SpotiFlyerDatabase
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.di.utils.ParallelExecutor
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.methods
import com.shabinder.database.Database
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
/*
* Ignore Deprecation
* Deprecation is only a Suggestion P-)
* */
@Suppress("DEPRECATION")
actual class Dir actual constructor(
private val logger: Kermit,
private val preferenceManager: PreferenceManager,
spotiFlyerDatabase: SpotiFlyerDatabase,
) {
@Suppress("DEPRECATION")
private val defaultBaseDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC).toString()
actual fun fileSeparator(): String = File.separator
actual fun imageCacheDir(): String = methods.value.platformActions.imageCacheDir
// fun call in order to always access Updated Value
actual fun defaultDir(): String = (preferenceManager.downloadDir ?: defaultBaseDir) +
File.separator + "SpotiFlyer" + File.separator
actual fun isPresent(path: String): Boolean = File(path).exists()
actual fun createDirectory(dirPath: String) {
val yourAppDir = File(dirPath)
if (!yourAppDir.exists() && !yourAppDir.isDirectory) { // create empty directory
if (yourAppDir.mkdirs()) { logger.i { "$dirPath created" } } else {
logger.e { "Unable to create Dir: $dirPath!" }
}
} else {
logger.i { "$dirPath already exists" }
}
}
@Suppress("unused")
actual suspend fun clearCache(): Unit = withContext(dispatcherIO) {
File(imageCacheDir()).deleteRecursively()
}
@Suppress("BlockingMethodInNonBlockingContext")
actual suspend fun saveFileWithMetadata(
mp3ByteArray: ByteArray,
trackDetails: TrackDetails,
postProcess: (track: TrackDetails) -> Unit
) = withContext(dispatcherIO) {
val songFile = File(trackDetails.outputFilePath)
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*/
songFile.parentFile?.mkdirs()
}
// Write Bytes to Media File
songFile.writeBytes(mp3ByteArray)
when (trackDetails.outputFilePath.substringAfterLast('.')) {
".mp3" -> {
Mp3File(File(songFile.absolutePath))
.removeAllTags()
.setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails)
addToLibrary(songFile.absolutePath)
}
".m4a" -> {
/*FFmpeg.executeAsync(
"-i ${m4aFile.absolutePath} -y -b:a 160k -acodec libmp3lame -vn ${m4aFile.absolutePath.substringBeforeLast('.') + ".mp3"}"
){ _, returnCode ->
when (returnCode) {
Config.RETURN_CODE_SUCCESS -> {
//FFMPEG task Completed
logger.d{ "Async command execution completed successfully." }
scope.launch {
Mp3File(File(m4aFile.absolutePath.substringBeforeLast('.') + ".mp3"))
.removeAllTags()
.setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails)
addToLibrary(m4aFile.absolutePath.substringBeforeLast('.') + ".mp3")
}
}
Config.RETURN_CODE_CANCEL -> {
logger.d{"Async command execution cancelled by user."}
}
else -> {
logger.d { "Async command execution failed with rc=$returnCode" }
}
}
}*/
}
else -> {
try {
Mp3File(File(songFile.absolutePath))
.removeAllTags()
.setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails)
addToLibrary(songFile.absolutePath)
} catch (e: Exception) { e.printStackTrace() }
}
}
} catch (e: Exception) {
if (songFile.exists()) songFile.delete()
logger.e { "${songFile.absolutePath} could not be created" }
}
}
actual fun addToLibrary(path: String) = methods.value.platformActions.addToLibrary(path)
actual suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture = withContext(dispatcherIO) {
val cachePath = imageCacheDir() + getNameURL(url)
Picture(image = (loadCachedImage(cachePath, reqWidth, reqHeight) ?: freshImage(url, reqWidth, reqHeight))?.asImageBitmap())
}
private fun loadCachedImage(cachePath: String, reqWidth: Int, reqHeight: Int): Bitmap? {
return try {
getMemoryEfficientBitmap(cachePath, reqWidth, reqHeight)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
@Suppress("BlockingMethodInNonBlockingContext")
actual suspend fun cacheImage(image: Any, path: String): Unit = withContext(dispatcherIO) {
try {
FileOutputStream(path).use { out ->
(image as? Bitmap)?.compress(Bitmap.CompressFormat.JPEG, 100, out)
}
} catch (e: IOException) {
e.printStackTrace()
}
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun freshImage(url: String, reqWidth: Int, reqHeight: Int): Bitmap? = withContext(dispatcherIO) {
try {
val source = URL(url)
val connection: HttpURLConnection = source.openConnection() as HttpURLConnection
connection.connectTimeout = 5000
connection.connect()
val input: ByteArray = connection.inputStream.readBytes()
// Get Memory Efficient Bitmap
val bitmap: Bitmap? = getMemoryEfficientBitmap(input, reqWidth, reqHeight)
parallelExecutor.execute {
// Decode and Cache Full Sized Image in Background
cacheImage(BitmapFactory.decodeByteArray(input, 0, input.size), imageCacheDir() + getNameURL(url))
}
bitmap // return Memory Efficient Bitmap
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/*
* Parallel Executor with 4 concurrent operation at a time.
* - We will use this to queue up operations and decode Full Sized Images
* - Will Decode Only 4 at a time , to avoid going into `Out of Memory`
* */
private val parallelExecutor = ParallelExecutor(Dispatchers.IO)
actual val db: Database? = spotiFlyerDatabase.instance
}

View File

@ -16,80 +16,30 @@
package com.shabinder.common.di
import co.touchlab.kermit.Kermit
import com.russhwolf.settings.Settings
import com.shabinder.common.core_components.coreComponentModules
import com.shabinder.common.database.databaseModule
import com.shabinder.common.database.getLogger
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.di.providers.providersModule
import io.ktor.client.*
import io.ktor.client.features.*
import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.*
import io.ktor.client.features.logging.*
import kotlinx.serialization.json.Json
import com.shabinder.common.providers.providersModule
import org.koin.core.KoinApplication
import org.koin.core.context.startKoin
import org.koin.core.module.Module
import org.koin.dsl.KoinAppDeclaration
import org.koin.dsl.module
import kotlin.native.concurrent.SharedImmutable
import kotlin.native.concurrent.ThreadLocal
fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclaration = {}) =
startKoin {
appDeclaration()
modules(
commonModule(enableNetworkLogs = enableNetworkLogs),
providersModule(),
databaseModule()
coreComponentModules(enableNetworkLogs),
listOf(
providersModule(enableNetworkLogs),
databaseModule(),
)
)
}
// Called by IOS
fun initKoin() = initKoin(enableNetworkLogs = false) { }
fun commonModule(enableNetworkLogs: Boolean) = module {
single { createHttpClient(enableNetworkLogs = enableNetworkLogs) }
single { Dir(get(), get(), get()) }
single { Settings() }
single { PreferenceManager(get()) }
single { Kermit(getLogger()) }
single { TokenStore(get(), get()) }
private fun KoinApplication.modules(vararg moduleLists: List<Module>): KoinApplication {
return modules(moduleLists.toList().flatten())
}
@ThreadLocal
val globalJson = Json {
isLenient = true
ignoreUnknownKeys = true
}
fun createHttpClient(enableNetworkLogs: Boolean = false) = HttpClient {
// https://github.com/Kotlin/kotlinx.serialization/issues/1450
install(JsonFeature) {
serializer = KotlinxSerializer(globalJson)
}
install(HttpTimeout) {
socketTimeoutMillis = 520_000
requestTimeoutMillis = 360_000
connectTimeoutMillis = 360_000
}
// WorkAround for Freezing
// Use httpClient.getData / httpClient.postData Extensions
/*install(JsonFeature) {
serializer = KotlinxSerializer(
Json {
isLenient = true
ignoreUnknownKeys = true
}
)
}*/
if (enableNetworkLogs) {
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.INFO
}
}
}
/*Client Active Throughout App's Lifetime*/
@SharedImmutable
val ktorHttpClient = HttpClient {}

View File

@ -1,156 +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.di
import co.touchlab.kermit.Kermit
import com.shabinder.common.database.DownloadRecordDatabaseQueries
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.di.providers.GaanaProvider
import com.shabinder.common.di.providers.SaavnProvider
import com.shabinder.common.di.providers.SpotifyProvider
import com.shabinder.common.di.providers.YoutubeMp3
import com.shabinder.common.di.providers.YoutubeMusic
import com.shabinder.common.di.providers.YoutubeProvider
import com.shabinder.common.di.providers.get
import com.shabinder.common.di.providers.requests.audioToMp3.AudioToMp3
import com.shabinder.common.models.AudioQuality
import com.shabinder.common.models.PlatformQueryResult
import com.shabinder.common.models.SpotiFlyerException
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.event.coroutines.flatMap
import com.shabinder.common.models.event.coroutines.flatMapError
import com.shabinder.common.models.event.coroutines.success
import com.shabinder.common.models.spotify.Source
import com.shabinder.common.requireNotNull
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class FetchPlatformQueryResult(
private val gaanaProvider: GaanaProvider,
private val spotifyProvider: SpotifyProvider,
private val youtubeProvider: YoutubeProvider,
private val saavnProvider: SaavnProvider,
private val youtubeMusic: YoutubeMusic,
private val youtubeMp3: YoutubeMp3,
private val audioToMp3: AudioToMp3,
val dir: Dir,
val preferenceManager: PreferenceManager,
val logger: Kermit
) {
private val db: DownloadRecordDatabaseQueries?
get() = dir.db?.downloadRecordDatabaseQueries
suspend fun authenticateSpotifyClient() = spotifyProvider.authenticateSpotifyClient()
suspend fun query(link: String): SuspendableEvent<PlatformQueryResult, Throwable> {
val result = when {
// SPOTIFY
link.contains("spotify", true) ->
spotifyProvider.query(link)
// YOUTUBE
link.contains("youtube.com", true) || link.contains("youtu.be", true) ->
youtubeProvider.query(link)
// Jio Saavn
link.contains("saavn", true) ->
saavnProvider.query(link)
// GAANA
link.contains("gaana", true) ->
gaanaProvider.query(link)
else -> {
SuspendableEvent.error(SpotiFlyerException.LinkInvalid(link))
}
}
result.success {
addToDatabaseAsync(
link,
it.copy() // Send a copy in order to not to freeze Result itself
)
}
return result
}
// 1) Try Finding on JioSaavn (better quality upto 320KBPS)
// 2) If Not found try finding on Youtube Music
suspend fun findMp3DownloadLink(
track: TrackDetails,
preferredQuality: AudioQuality = preferenceManager.audioQuality
): SuspendableEvent<String, Throwable> =
if (track.videoID != null) {
// We Already have VideoID
when (track.source) {
Source.JioSaavn -> {
saavnProvider.getSongFromID(track.videoID.requireNotNull()).flatMap { song ->
song.media_url?.let { audioToMp3.convertToMp3(it) } ?: findMp3Link(track, preferredQuality)
}
}
Source.YouTube -> {
youtubeMp3.getMp3DownloadLink(track.videoID.requireNotNull(), preferredQuality).flatMapError {
logger.e("Yt1sMp3 Failed") { it.message ?: "couldn't fetch link for ${track.videoID} ,trying manual extraction" }
youtubeProvider.ytDownloader.getVideo(track.videoID!!).get()?.url?.let { m4aLink ->
audioToMp3.convertToMp3(m4aLink)
} ?: throw SpotiFlyerException.YoutubeLinkNotFound(track.videoID)
}
}
else -> {
/*We should never reach here for now*/
findMp3Link(track, preferredQuality)
}
}
} else {
findMp3Link(track, preferredQuality)
}
private suspend fun findMp3Link(
track: TrackDetails,
preferredQuality: AudioQuality
): SuspendableEvent<String, Throwable> {
// Try Fetching Track from Jio Saavn
return saavnProvider.findMp3SongDownloadURL(
trackName = track.title,
trackArtists = track.artists,
preferredQuality = preferredQuality
).flatMapError { saavnError ->
logger.e { "Fetching From Saavn Failed: \n${saavnError.stackTraceToString()}" }
// Saavn Failed, Lets Try Fetching Now From Youtube Music
youtubeMusic.findMp3SongDownloadURLYT(track, preferredQuality).flatMapError { ytMusicError ->
// If Both Failed Bubble the Exception Up with both StackTraces
SuspendableEvent.error(
SpotiFlyerException.DownloadLinkFetchFailed(
trackName = track.title,
ytMusicError = ytMusicError,
jioSaavnError = saavnError
)
)
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun addToDatabaseAsync(link: String, result: PlatformQueryResult) {
GlobalScope.launch(dispatcherIO) {
db?.add(
result.folderType, result.title, link, result.coverUrl, result.trackList.size.toLong()
)
}
}
}

View File

@ -1,19 +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.di
expect class Picture

View File

@ -1,16 +0,0 @@
package com.shabinder.common.di.providers
import com.shabinder.common.di.FetchPlatformQueryResult
import com.shabinder.common.di.providers.requests.audioToMp3.AudioToMp3
import org.koin.dsl.module
fun providersModule() = module {
single { AudioToMp3(get(), get()) }
single { SpotifyProvider(get(), get(), get()) }
single { GaanaProvider(get(), get(), get()) }
single { SaavnProvider(get(), get(), get(), get()) }
single { YoutubeProvider(get(), get(), get()) }
single { YoutubeMp3(get(), get()) }
single { YoutubeMusic(get(), get(), get(), get(), get()) }
single { FetchPlatformQueryResult(get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) }
}

View File

@ -1,44 +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.di.providers
import co.touchlab.kermit.Kermit
import com.shabinder.common.di.providers.requests.youtubeMp3.Yt1sMp3
import com.shabinder.common.models.AudioQuality
import com.shabinder.common.models.corsApi
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.event.coroutines.map
import io.ktor.client.*
interface YoutubeMp3 : Yt1sMp3 {
companion object {
operator fun invoke(
client: HttpClient,
logger: Kermit
): YoutubeMp3 {
return object : YoutubeMp3 {
override val httpClient: HttpClient = client
override val logger: Kermit = logger
}
}
}
suspend fun getMp3DownloadLink(videoID: String, quality: AudioQuality): SuspendableEvent<String, Throwable> = getLinkFromYt1sMp3(videoID, quality).map {
corsApi + it
}
}

View File

@ -1,125 +0,0 @@
package com.shabinder.common.di.providers.requests.audioToMp3
import co.touchlab.kermit.Kermit
import com.shabinder.common.models.AudioQuality
import com.shabinder.common.models.SpotiFlyerException
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import io.ktor.client.*
import io.ktor.client.features.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.delay
interface AudioToMp3 {
val client: HttpClient
val logger: Kermit
companion object {
operator fun invoke(
client: HttpClient,
logger: Kermit
): AudioToMp3 {
return object : AudioToMp3 {
override val client: HttpClient = client
override val logger: Kermit = logger
}
}
}
suspend fun convertToMp3(
URL: String,
audioQuality: AudioQuality = AudioQuality.getQuality(URL.substringBeforeLast(".").takeLast(3)),
): SuspendableEvent<String, Throwable> = SuspendableEvent {
// Active Host ex - https://hostveryfast.onlineconverter.com/file/send
// Convert Job Request ex - https://www.onlineconverter.com/convert/309a0f2bbaeb5687b04f96b6d65b47bfdd
var (activeHost, jobLink) = convertRequest(URL, audioQuality).value
// (jobStatus.contains("d")) == COMPLETION
var jobStatus: String
var retryCount = 40 // Set it to optimal level
do {
jobStatus = try {
client.get(
"${activeHost.removeSuffix("send")}${jobLink.substringAfterLast("/")}"
)
} catch (e: Exception) {
e.printStackTrace()
if (e is ClientRequestException && e.response.status.value == 404) {
// No Need to Retry, Host/Converter is Busy
throw SpotiFlyerException.MP3ConversionFailed(e.message)
}
// Try Using New Host/Converter
convertRequest(URL, audioQuality).value.also {
activeHost = it.first
jobLink = it.second
}
""
}
retryCount--
logger.i("Job Status") { jobStatus }
if (!jobStatus.contains("d")) delay(600) // Add Delay , to give Server Time to process audio
} while (!jobStatus.contains("d", true) && retryCount > 0)
"${activeHost.removeSuffix("send")}${jobLink.substringAfterLast("/")}/download"
}
/*
* Response Link Ex : `https://www.onlineconverter.com/convert/11affb6d88d31861fe5bcd33da7b10a26c`
* - to start the conversion
* */
private suspend fun convertRequest(
URL: String,
audioQuality: AudioQuality = AudioQuality.KBPS160,
): SuspendableEvent<Pair<String, String>, Throwable> = SuspendableEvent {
val activeHost by getHost()
val convertJob = client.submitFormWithBinaryData<String>(
url = activeHost,
formData = formData {
append("class", "audio")
append("from", "audio")
append("to", "mp3")
append("source", "url")
append("url", URL.replace("https:", "http:"))
append("audio_quality", audioQuality.kbps)
}
) {
headers {
header("Host", activeHost.getHostDomain().also { logger.i("AudioToMp3 Host") { it } })
header("Origin", "https://www.onlineconverter.com")
header("Referer", "https://www.onlineconverter.com/")
}
}.run {
// logger.d { this }
dropLast(3) // last 3 are useless unicode char
}
val job = client.get<HttpStatement>(convertJob) {
headers {
header("Host", "www.onlineconverter.com")
}
}.execute()
logger.i("Schedule Conversion Job") { job.status.isSuccess().toString() }
Pair(activeHost, convertJob)
}
// Active Host free to process conversion
// ex - https://hostveryfast.onlineconverter.com/file/send
private suspend fun getHost(): SuspendableEvent<String, Throwable> = SuspendableEvent {
client.get<String>("https://www.onlineconverter.com/get/host") {
headers {
header("Host", "www.onlineconverter.com")
}
} // .also { logger.i("Active Host") { it } }
}
// Extract full Domain from URL
// ex - hostveryfast.onlineconverter.com
private fun String.getHostDomain(): String {
return this.removePrefix("https://").substringBeforeLast(".") + "." + this.substringAfterLast(".").substringBefore("/")
}
}

View File

@ -1,23 +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.di
import androidx.compose.ui.graphics.ImageBitmap
actual data class Picture(
var image: ImageBitmap?
)

View File

@ -1,123 +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.di
import co.touchlab.kermit.Kermit
import com.shabinder.common.database.SpotiFlyerDatabase
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.di.utils.removeIllegalChars
import com.shabinder.common.models.DownloadResult
import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.corsApi
import com.shabinder.database.Database
import kotlinext.js.Object
import kotlinext.js.js
import kotlinx.coroutines.flow.collect
import org.khronos.webgl.ArrayBuffer
import org.khronos.webgl.Int8Array
import org.w3c.dom.ImageBitmap
actual class Dir actual constructor(
private val logger: Kermit,
private val preferenceManager: PreferenceManager,
spotiFlyerDatabase: SpotiFlyerDatabase,
) {
/*init {
createDirectories()
}*/
/*
* TODO
* */
actual fun fileSeparator(): String = "/"
actual fun imageCacheDir(): String = "TODO" +
fileSeparator() + "SpotiFlyer/.images" + fileSeparator()
actual fun defaultDir(): String = "TODO" + fileSeparator() +
"SpotiFlyer" + fileSeparator()
actual fun isPresent(path: String): Boolean = false
actual fun createDirectory(dirPath: String) {}
actual suspend fun clearCache() {}
actual suspend fun cacheImage(image: Any, path: String) {}
@Suppress("BlockingMethodInNonBlockingContext")
actual suspend fun saveFileWithMetadata(
mp3ByteArray: ByteArray,
trackDetails: TrackDetails,
postProcess: (track: TrackDetails) -> Unit
) {
val writer = ID3Writer(mp3ByteArray.toArrayBuffer())
val albumArt = downloadFile(corsApi + trackDetails.albumArtURL)
albumArt.collect {
when (it) {
is DownloadResult.Success -> {
logger.d { "Album Art Downloaded Success" }
val albumArtObj = js {
this["type"] = 3
this["data"] = it.byteArray.toArrayBuffer()
this["description"] = "Cover Art"
}
writeTagsAndSave(writer, albumArtObj as Object, trackDetails)
}
is DownloadResult.Error -> {
logger.d { "Album Art Downloading Error" }
writeTagsAndSave(writer, null, trackDetails)
}
is DownloadResult.Progress -> logger.d { "Album Art Downloading: ${it.progress}" }
}
}
}
private suspend fun writeTagsAndSave(writer: ID3Writer, albumArt: Object?, trackDetails: TrackDetails) {
writer.apply {
setFrame("TIT2", trackDetails.title)
setFrame("TPE1", trackDetails.artists.toTypedArray())
setFrame("TALB", trackDetails.albumName ?: "")
try { trackDetails.year?.substring(0, 4)?.toInt()?.let { setFrame("TYER", it) } } catch (e: Exception) {}
setFrame("TPE2", trackDetails.artists.joinToString(","))
setFrame("WOAS", trackDetails.source.toString())
setFrame("TLEN", trackDetails.durationSec)
albumArt?.let { setFrame("APIC", it) }
}
writer.addTag()
allTracksStatus[trackDetails.title] = DownloadStatus.Downloaded
DownloadProgressFlow.emit(allTracksStatus)
saveAs(writer.getBlob(), "${removeIllegalChars(trackDetails.title)}.mp3")
}
actual fun addToLibrary(path: String) {}
actual suspend fun loadImage(url: String, reqWidth: Int, reqHeight: Int): Picture {
return Picture(url)
}
private fun loadCachedImage(cachePath: String): ImageBitmap? = null
private suspend fun freshImage(url: String): ImageBitmap? = null
actual val db: Database? = spotiFlyerDatabase.instance
}
fun ByteArray.toArrayBuffer(): ArrayBuffer {
return this.unsafeCast<Int8Array>().buffer
}

View File

@ -1,21 +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.di
actual data class Picture(
var imageUrl: String
)

View File

@ -28,6 +28,8 @@ kotlin {
implementation(project(":common:dependency-injection"))
implementation(project(":common:data-models"))
implementation(project(":common:database"))
implementation(project(":common:providers"))
implementation(project(":common:core-components"))
implementation(SqlDelight.coroutineExtensions)
}
}

View File

@ -19,15 +19,15 @@ package com.shabinder.common.list
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.value.Value
import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.shabinder.common.di.Dir
import com.shabinder.common.di.FetchPlatformQueryResult
import com.shabinder.common.di.Picture
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.core_components.file_manager.FileManager
import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.core_components.preference_manager.PreferenceManager
import com.shabinder.common.list.integration.SpotiFlyerListImpl
import com.shabinder.common.models.Consumer
import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.PlatformQueryResult
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.providers.FetchPlatformQueryResult
import kotlinx.coroutines.flow.MutableSharedFlow
interface SpotiFlyerList {
@ -67,7 +67,7 @@ interface SpotiFlyerList {
interface Dependencies {
val storeFactory: StoreFactory
val fetchQuery: FetchPlatformQueryResult
val dir: Dir
val fileManager: FileManager
val preferenceManager: PreferenceManager
val link: String
val listOutput: Consumer<Output>

View File

@ -18,11 +18,11 @@ package com.shabinder.common.list.integration
import co.touchlab.stately.ensureNeverFrozen
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.lifecycle.doOnResume
import com.arkivanov.decompose.value.Value
import com.arkivanov.essenty.lifecycle.doOnResume
import com.shabinder.common.caching.Cache
import com.shabinder.common.di.Picture
import com.shabinder.common.di.utils.asValue
import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.core_components.utils.asValue
import com.shabinder.common.list.SpotiFlyerList
import com.shabinder.common.list.SpotiFlyerList.Dependencies
import com.shabinder.common.list.SpotiFlyerList.State
@ -45,14 +45,7 @@ internal class SpotiFlyerListImpl(
private val store =
instanceKeeper.getStore {
SpotiFlyerListStoreProvider(
dir = this.dir,
preferenceManager = preferenceManager,
storeFactory = storeFactory,
fetchQuery = fetchQuery,
downloadProgressFlow = downloadProgressFlow,
link = link
).provide()
SpotiFlyerListStoreProvider(dependencies).provide()
}
private val cache = Cache.Builder
@ -84,8 +77,8 @@ internal class SpotiFlyerListImpl(
override suspend fun loadImage(url: String, isCover: Boolean): Picture {
return cache.get(url) {
if (isCover) dir.loadImage(url, 350, 350)
else dir.loadImage(url, 150, 150)
if (isCover) fileManager.loadImage(url, 350, 350)
else fileManager.loadImage(url, 150, 150)
}
}
}

View File

@ -16,8 +16,8 @@
package com.shabinder.common.list.store
import com.arkivanov.decompose.instancekeeper.InstanceKeeper
import com.arkivanov.decompose.instancekeeper.getOrCreate
import com.arkivanov.essenty.instancekeeper.InstanceKeeper
import com.arkivanov.essenty.instancekeeper.getOrCreate
import com.arkivanov.mvikotlin.core.store.Store
fun <T : Store<*, *, *>> InstanceKeeper.getStore(key: Any, factory: () -> T): T =

View File

@ -19,29 +19,19 @@ package com.shabinder.common.list.store
import com.arkivanov.mvikotlin.core.store.Reducer
import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper
import com.arkivanov.mvikotlin.core.store.Store
import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor
import com.shabinder.common.di.Dir
import com.shabinder.common.di.FetchPlatformQueryResult
import com.shabinder.common.di.downloadTracks
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.list.SpotiFlyerList
import com.shabinder.common.list.SpotiFlyerList.State
import com.shabinder.common.list.store.SpotiFlyerListStore.Intent
import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.PlatformQueryResult
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.methods
import kotlinx.coroutines.flow.MutableSharedFlow
import com.shabinder.common.providers.downloadTracks
import kotlinx.coroutines.flow.collect
internal class SpotiFlyerListStoreProvider(
private val dir: Dir,
private val preferenceManager: PreferenceManager,
private val storeFactory: StoreFactory,
private val fetchQuery: FetchPlatformQueryResult,
private val link: String,
private val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
) {
internal class SpotiFlyerListStoreProvider(dependencies: SpotiFlyerList.Dependencies) :
SpotiFlyerList.Dependencies by dependencies {
fun provide(): SpotiFlyerListStore =
object :
SpotiFlyerListStore,
@ -66,7 +56,7 @@ internal class SpotiFlyerListStoreProvider(
override suspend fun executeAction(action: Unit, getState: () -> State) {
executeIntent(Intent.SearchLink(link), getState)
dir.db?.downloadRecordDatabaseQueries?.getLastInsertId()?.executeAsOneOrNull()?.also {
fileManager.db?.downloadRecordDatabaseQueries?.getLastInsertId()?.executeAsOneOrNull()?.also {
// See if It's Time we can request for support for maintaining this project or not
fetchQuery.logger.d(message = { "Database List Last ID: $it" }, tag = "Database Last ID")
val offset = preferenceManager.getDonationOffset
@ -92,7 +82,12 @@ internal class SpotiFlyerListStoreProvider(
resp.fold(
success = { result ->
result.trackList = result.trackList.toMutableList()
dispatch((Result.ResultFetched(result, result.trackList.updateTracksStatuses(downloadProgressFlow.replayCache.getOrElse(0) { hashMapOf() }))))
dispatch(
(Result.ResultFetched(
result,
result.trackList.updateTracksStatuses(downloadProgressFlow.replayCache.getOrElse(0) { hashMapOf() })
))
)
executeIntent(Intent.RefreshTracksStatuses, getState)
},
failure = {
@ -103,25 +98,32 @@ internal class SpotiFlyerListStoreProvider(
is Intent.StartDownloadAll -> {
val list = intent.trackList.map {
if (it.downloaded == DownloadStatus.NotDownloaded)
if (it.downloaded is DownloadStatus.NotDownloaded || it.downloaded is DownloadStatus.Failed)
return@map it.copy(downloaded = DownloadStatus.Queued)
it
}
dispatch(Result.UpdateTrackList(list.updateTracksStatuses(downloadProgressFlow.replayCache.getOrElse(0) { hashMapOf() })))
dispatch(
Result.UpdateTrackList(
list.updateTracksStatuses(
downloadProgressFlow.replayCache.getOrElse(
0
) { hashMapOf() })
)
)
val finalList =
intent.trackList.filter { it.downloaded == DownloadStatus.NotDownloaded }
if (finalList.isNullOrEmpty()) methods.value.showPopUpMessage("All Songs are Processed")
else downloadTracks(finalList, fetchQuery, dir)
val finalList = intent.trackList.filter { it.downloaded == DownloadStatus.NotDownloaded }
if (finalList.isEmpty()) methods.value.showPopUpMessage("All Songs are Processed")
else downloadTracks(finalList, fetchQuery, fileManager)
}
is Intent.StartDownload -> {
dispatch(Result.UpdateTrackItem(intent.track.copy(downloaded = DownloadStatus.Queued)))
downloadTracks(listOf(intent.track), fetchQuery, dir)
downloadTracks(listOf(intent.track), fetchQuery, fileManager)
}
is Intent.RefreshTracksStatuses -> methods.value.queryActiveTracks()
}
}
}
private object ReducerImpl : Reducer<State, Result> {
override fun State.reduce(result: Result): State =
when (result) {
@ -140,6 +142,7 @@ internal class SpotiFlyerListStoreProvider(
return this
}
}
private fun List<TrackDetails>.updateTracksStatuses(map: HashMap<String, DownloadStatus>): List<TrackDetails> {
val titleList = this.map { it.title }
val updatedList = mutableListOf<TrackDetails>().also { it.addAll(this) }
@ -147,7 +150,11 @@ internal class SpotiFlyerListStoreProvider(
for (newTrack in map) {
titleList.indexOf(newTrack.key).let { position ->
if (position != -1) {
updatedList.getOrNull(position)?.copy(downloaded = newTrack.value, progress = (newTrack.value as? DownloadStatus.Downloading)?.progress ?: updatedList[position].progress)?.also { updatedTrack ->
updatedList.getOrNull(position)?.copy(
downloaded = newTrack.value,
progress = (newTrack.value as? DownloadStatus.Downloading)?.progress
?: updatedList[position].progress
)?.also { updatedTrack ->
updatedList[position] = updatedTrack
// logger.d("$position) ${updatedTrack.downloaded} - ${updatedTrack.title}","List Store Track Update")
}

View File

@ -28,6 +28,8 @@ kotlin {
implementation(project(":common:dependency-injection"))
implementation(project(":common:data-models"))
implementation(project(":common:database"))
implementation(project(":common:providers"))
implementation(project(":common:core-components"))
implementation(SqlDelight.coroutineExtensions)
}
}

View File

@ -19,9 +19,10 @@ package com.shabinder.common.main
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.value.Value
import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.shabinder.common.di.Dir
import com.shabinder.common.di.Picture
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.core_components.analytics.AnalyticsManager
import com.shabinder.common.core_components.file_manager.FileManager
import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.core_components.preference_manager.PreferenceManager
import com.shabinder.common.main.integration.SpotiFlyerMainImpl
import com.shabinder.common.models.Consumer
import com.shabinder.common.models.DownloadRecord
@ -65,8 +66,9 @@ interface SpotiFlyerMain {
val mainOutput: Consumer<Output>
val storeFactory: StoreFactory
val database: Database?
val dir: Dir
val fileManager: FileManager
val preferenceManager: PreferenceManager
val analyticsManager: AnalyticsManager
val mainAnalytics: Analytics
}

View File

@ -18,16 +18,13 @@ package com.shabinder.common.main.integration
import co.touchlab.stately.ensureNeverFrozen
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.lifecycle.doOnResume
import com.arkivanov.decompose.value.Value
import com.arkivanov.essenty.lifecycle.doOnResume
import com.shabinder.common.caching.Cache
import com.shabinder.common.di.Picture
import com.shabinder.common.di.utils.asValue
import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.core_components.utils.asValue
import com.shabinder.common.main.SpotiFlyerMain
import com.shabinder.common.main.SpotiFlyerMain.Dependencies
import com.shabinder.common.main.SpotiFlyerMain.HomeCategory
import com.shabinder.common.main.SpotiFlyerMain.Output
import com.shabinder.common.main.SpotiFlyerMain.State
import com.shabinder.common.main.SpotiFlyerMain.*
import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent
import com.shabinder.common.main.store.SpotiFlyerMainStoreProvider
import com.shabinder.common.main.store.getStore
@ -47,12 +44,7 @@ internal class SpotiFlyerMainImpl(
private val store =
instanceKeeper.getStore {
SpotiFlyerMainStoreProvider(
preferenceManager = preferenceManager,
storeFactory = storeFactory,
database = database,
dir = dir
).provide()
SpotiFlyerMainStoreProvider(dependencies).provide()
}
private val cache = Cache.Builder
@ -83,7 +75,7 @@ internal class SpotiFlyerMainImpl(
override suspend fun loadImage(url: String): Picture {
return cache.get(url) {
dir.loadImage(url, 150, 150)
fileManager.loadImage(url, 150, 150)
}
}

View File

@ -16,8 +16,8 @@
package com.shabinder.common.main.store
import com.arkivanov.decompose.instancekeeper.InstanceKeeper
import com.arkivanov.decompose.instancekeeper.getOrCreate
import com.arkivanov.essenty.instancekeeper.InstanceKeeper
import com.arkivanov.essenty.instancekeeper.getOrCreate
import com.arkivanov.mvikotlin.core.store.Store
fun <T : Store<*, *, *>> InstanceKeeper.getStore(key: Any, factory: () -> T): T =

View File

@ -19,16 +19,12 @@ package com.shabinder.common.main.store
import com.arkivanov.mvikotlin.core.store.Reducer
import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper
import com.arkivanov.mvikotlin.core.store.Store
import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor
import com.shabinder.common.di.Dir
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.main.SpotiFlyerMain
import com.shabinder.common.main.SpotiFlyerMain.State
import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent
import com.shabinder.common.models.DownloadRecord
import com.shabinder.common.models.methods
import com.shabinder.database.Database
import com.squareup.sqldelight.runtime.coroutines.asFlow
import com.squareup.sqldelight.runtime.coroutines.mapToList
import kotlinx.coroutines.Dispatchers
@ -36,12 +32,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
internal class SpotiFlyerMainStoreProvider(
private val storeFactory: StoreFactory,
private val preferenceManager: PreferenceManager,
private val dir: Dir,
database: Database?
) {
internal class SpotiFlyerMainStoreProvider(dependencies: SpotiFlyerMain.Dependencies): SpotiFlyerMain.Dependencies by dependencies {
fun provide(): SpotiFlyerMainStore =
object :

View File

@ -28,6 +28,8 @@ kotlin {
implementation(project(":common:dependency-injection"))
implementation(project(":common:data-models"))
implementation(project(":common:database"))
implementation(project(":common:core-components"))
implementation(project(":common:providers"))
implementation(SqlDelight.coroutineExtensions)
}
}

View File

@ -19,9 +19,10 @@ package com.shabinder.common.preference
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.value.Value
import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.shabinder.common.di.Dir
import com.shabinder.common.di.Picture
import com.shabinder.common.di.preference.PreferenceManager
import com.shabinder.common.core_components.analytics.AnalyticsManager
import com.shabinder.common.core_components.file_manager.FileManager
import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.core_components.preference_manager.PreferenceManager
import com.shabinder.common.models.Actions
import com.shabinder.common.models.AudioQuality
import com.shabinder.common.models.Consumer
@ -44,8 +45,9 @@ interface SpotiFlyerPreference {
interface Dependencies {
val prefOutput: Consumer<Output>
val storeFactory: StoreFactory
val dir: Dir
val fileManager: FileManager
val preferenceManager: PreferenceManager
val analyticsManager: AnalyticsManager
val actions: Actions
val preferenceAnalytics: Analytics
}
@ -64,5 +66,8 @@ interface SpotiFlyerPreference {
}
@Suppress("FunctionName") // Factory function
fun SpotiFlyerPreference(componentContext: ComponentContext, dependencies: SpotiFlyerPreference.Dependencies): SpotiFlyerPreference =
fun SpotiFlyerPreference(
componentContext: ComponentContext,
dependencies: SpotiFlyerPreference.Dependencies
): SpotiFlyerPreference =
SpotiFlyerPreferenceImpl(componentContext, dependencies)

View File

@ -20,8 +20,8 @@ import co.touchlab.stately.ensureNeverFrozen
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.value.Value
import com.shabinder.common.caching.Cache
import com.shabinder.common.di.Picture
import com.shabinder.common.di.utils.asValue
import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.core_components.utils.asValue
import com.shabinder.common.models.AudioQuality
import com.shabinder.common.preference.SpotiFlyerPreference
import com.shabinder.common.preference.SpotiFlyerPreference.Dependencies
@ -41,12 +41,7 @@ internal class SpotiFlyerPreferenceImpl(
private val store =
instanceKeeper.getStore {
SpotiFlyerPreferenceStoreProvider(
storeFactory = storeFactory,
preferenceManager = preferenceManager,
dir = dir,
actions = actions
).provide()
SpotiFlyerPreferenceStoreProvider(dependencies).provide()
}
private val cache = Cache.Builder
@ -74,7 +69,7 @@ internal class SpotiFlyerPreferenceImpl(
override suspend fun loadImage(url: String): Picture {
return cache.get(url) {
dir.loadImage(url, 150, 150)
fileManager.loadImage(url, 150, 150)
}
}
}

Some files were not shown because too many files have changed in this diff Show More