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
Gemfile.lock Gemfile.lock
/maintenance-tasks/build/ /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"] [submodule "spotiflyer-ios"]
path = spotiflyer-ios path = spotiflyer-ios
url = https://github.com/Shabinder/spotiflyer-ios url = https://github.com/Shabinder/spotiflyer-ios
[submodule "mosaic"] [submodule "ffmpeg/ffmpeg-android-maker"]
path = mosaic path = ffmpeg/ffmpeg-android-maker
url = https://github.com/JakeWharton/mosaic 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/>. * * 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 com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import org.jetbrains.compose.compose import org.jetbrains.compose.compose
import org.jetbrains.kotlin.kapt.cli.main
plugins { plugins {
id("com.android.application") id("com.android.application")
@ -32,14 +32,10 @@ version = Versions.versionName
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
// Remove jcenter as soon as following issue closes
// https://github.com/matomo-org/matomo-sdk-android/issues/301
jcenter()
} }
android { android {
val props = gradleLocalProperties(rootDir) val props = gradleLocalProperties(rootDir)
if (props.containsKey("storeFileDir")) { if (props.containsKey("storeFileDir")) {
signingConfigs { signingConfigs {
create("release") { create("release") {
@ -51,17 +47,16 @@ android {
} }
} }
compileSdkVersion(Versions.compileSdkVersion) compileSdk = Versions.compileSdkVersion
buildToolsVersion = "30.0.3" buildToolsVersion = "30.0.3"
defaultConfig { defaultConfig {
applicationId = "com.shabinder.spotiflyer" applicationId = "com.shabinder.spotiflyer"
minSdkVersion(Versions.minSdkVersion) minSdk = Versions.minSdkVersion
targetSdkVersion(Versions.targetSdkVersion) targetSdk = Versions.targetSdkVersion
versionCode = Versions.versionCode versionCode = Versions.versionCode
versionName = Versions.versionName versionName = Versions.versionName
} }
buildTypes { buildTypes {
getByName("release") { getByName("release") {
isMinifyEnabled = true isMinifyEnabled = true
@ -69,11 +64,13 @@ android {
if (props.containsKey("storeFileDir")) { if (props.containsKey("storeFileDir")) {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
} }
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
} }
} }
kotlinOptions { kotlinOptions {
useIR = true
jvmTarget = "1.8" jvmTarget = "1.8"
} }
compileOptions { compileOptions {
@ -91,9 +88,6 @@ android {
exclude(group = "androidx.compose.ui") exclude(group = "androidx.compose.ui")
} }
} }
packagingOptions {
exclude("META-INF/*")
}
} }
dependencies { dependencies {
implementation(compose.material) implementation(compose.material)
@ -106,6 +100,8 @@ dependencies {
implementation(project(":common:root")) implementation(project(":common:root"))
implementation(project(":common:dependency-injection")) implementation(project(":common:dependency-injection"))
implementation(project(":common:data-models")) implementation(project(":common:data-models"))
implementation(project(":common:core-components"))
implementation(project(":common:providers"))
// Koin // Koin
implementation(Koin.android) implementation(Koin.android)
@ -123,10 +119,8 @@ dependencies {
// Extras // Extras
with(Extras.Android) { with(Extras.Android) {
implementation(Acra.notification) implementation(countly)
implementation(Acra.http)
implementation(appUpdator) implementation(appUpdator)
implementation(matomo)
} }
with(Versions.androidxLifecycle) { with(Versions.androidxLifecycle) {
@ -138,7 +132,7 @@ dependencies {
// implementation("com.jakewharton.timber:timber:4.7.1") // implementation("com.jakewharton.timber:timber:4.7.1")
implementation("dev.icerock.moko:parcelize:${Versions.mokoParcelize}") implementation("dev.icerock.moko:parcelize:${Versions.mokoParcelize}")
implementation("com.github.shabinder:storage-chooser:2.0.4.45") 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 // Test
testImplementation("junit:junit:4.13.2") 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.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application <application
android:name=".App" android:name=".App"
@ -48,11 +49,13 @@
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:theme="@style/Theme.AppCompat.Light.NoActionBar" android:theme="@style/Theme.AppCompat.Light.NoActionBar"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:hardwareAccelerated="true"
android:largeHeap="true" android:largeHeap="true"
android:label="SpotiFlyer" android:label="SpotiFlyer"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:configChanges="orientation|screenSize" android:configChanges="orientation|screenSize"
android:forceDarkAllowed="true" android:forceDarkAllowed="true"
android:extractNativeLibs="true"
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
tools:targetApi="q"> tools:targetApi="q">
<activity android:name=".MainActivity" <activity android:name=".MainActivity"
@ -73,5 +76,16 @@
</activity> </activity>
<service android:name=".service.ForegroundService"/> <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> </application>
</manifest> </manifest>

View File

@ -17,22 +17,12 @@
package com.shabinder.spotiflyer package com.shabinder.spotiflyer
import android.app.Application import android.app.Application
import android.content.Context
import com.shabinder.common.di.initKoin import com.shabinder.common.di.initKoin
import com.shabinder.common.translations.Strings
import com.shabinder.spotiflyer.di.appModule 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.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.logger.Level import org.koin.core.logger.Level
import org.matomo.sdk.Matomo
import org.matomo.sdk.Tracker
import org.matomo.sdk.TrackerBuilder
class App : Application(), KoinComponent { class App : Application(), KoinComponent {
@ -40,21 +30,6 @@ class App : Application(), KoinComponent {
const val SIGNATURE_HEX = "53304f6d75736a2f30484230334c454b714753525763724259444d3d0a" 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() { override fun onCreate() {
super.onCreate() super.onCreate()
@ -66,35 +41,4 @@ class App : Application(), KoinComponent {
modules(appModule(loggingEnabled)) 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 package com.shabinder.spotiflyer
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ClipData import android.content.*
import android.content.ClipboardManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
@ -40,20 +35,14 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
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.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.jetbrains.rememberRootComponent import com.arkivanov.decompose.defaultComponentContext
import com.arkivanov.mvikotlin.logging.store.LoggingStoreFactory import com.arkivanov.mvikotlin.logging.store.LoggingStoreFactory
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
import com.codekidlabs.storagechooser.R import com.codekidlabs.storagechooser.R
@ -62,19 +51,15 @@ import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsPadding import com.google.accompanist.insets.navigationBarsPadding
import com.google.accompanist.insets.statusBarsHeight import com.google.accompanist.insets.statusBarsHeight
import com.google.accompanist.insets.statusBarsPadding import com.google.accompanist.insets.statusBarsPadding
import com.shabinder.common.di.ConnectionLiveData import com.shabinder.common.core_components.ConnectionLiveData
import com.shabinder.common.di.Dir import com.shabinder.common.core_components.analytics.AnalyticsManager
import com.shabinder.common.di.FetchPlatformQueryResult 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.observeAsState
import com.shabinder.common.di.preference.PreferenceManager import com.shabinder.common.models.*
import com.shabinder.common.models.Actions
import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.PlatformActions
import com.shabinder.common.models.PlatformActions.Companion.SharedPreferencesKey import com.shabinder.common.models.PlatformActions.Companion.SharedPreferencesKey
import com.shabinder.common.models.TrackDetails import com.shabinder.common.providers.FetchPlatformQueryResult
import com.shabinder.common.models.methods
import com.shabinder.common.root.SpotiFlyerRoot import com.shabinder.common.root.SpotiFlyerRoot
import com.shabinder.common.root.SpotiFlyerRoot.Analytics
import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks
import com.shabinder.common.translations.Strings import com.shabinder.common.translations.Strings
import com.shabinder.common.uikit.configurations.SpotiFlyerTheme 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.AnalyticsDialog
import com.shabinder.spotiflyer.ui.NetworkDialog import com.shabinder.spotiflyer.ui.NetworkDialog
import com.shabinder.spotiflyer.ui.PermissionDialog import com.shabinder.spotiflyer.ui.PermissionDialog
import com.shabinder.spotiflyer.utils.checkAppSignature import com.shabinder.spotiflyer.utils.*
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 kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.matomo.sdk.extra.TrackHelper import org.koin.core.parameter.parametersOf
import java.io.File import java.io.File
@ExperimentalAnimationApi @ExperimentalAnimationApi
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val fetcher: FetchPlatformQueryResult by inject() 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 val preferenceManager: PreferenceManager by inject()
private lateinit var root: SpotiFlyerRoot private val analyticsManager: AnalyticsManager by inject { parametersOf(this) }
private val callBacks: SpotiFlyerRootCallBacks get() = root.callBacks private val callBacks: SpotiFlyerRootCallBacks get() = this.rootComponent.callBacks
private val trackStatusFlow = MutableSharedFlow<HashMap<String, DownloadStatus>>(1) private val trackStatusFlow = MutableSharedFlow<HashMap<String, DownloadStatus>>(1)
private var permissionGranted = mutableStateOf(true) private var permissionGranted = mutableStateOf(true)
private val internetAvailability by lazy { ConnectionLiveData(applicationContext) } private val internetAvailability by lazy { ConnectionLiveData(applicationContext) }
private val tracker get() = (application as App).tracker private lateinit var rootComponent: SpotiFlyerRoot
private val visibleChild get(): SpotiFlyerRoot.Child = root.routerState.value.activeChild.instance // private val visibleChild get(): SpotiFlyerRoot.Child = root.routerState.value.activeChild.instance
// Variable for storing instance of our service class // Variable for storing instance of our service class
var foregroundService: ForegroundService? = null var foregroundService: ForegroundService? = null
@ -120,9 +101,10 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
preferenceManager.analyticsManager = analyticsManager
// This app draws behind the system bars, so we want to handle fitting system windows // This app draws behind the system bars, so we want to handle fitting system windows
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
this.rootComponent = spotiFlyerRoot(defaultComponentContext())
setContent { setContent {
SpotiFlyerTheme { SpotiFlyerTheme {
Surface(contentColor = colorOffWhite) { Surface(contentColor = colorOffWhite) {
@ -131,8 +113,8 @@ class MainActivity : ComponentActivity() {
val view = LocalView.current val view = LocalView.current
Box { Box {
root = SpotiFlyerRootContent( SpotiFlyerRootContent(
rememberRootComponent(::spotiFlyerRoot), this@MainActivity.rootComponent,
Modifier.statusBarsPadding().navigationBarsPadding() Modifier.statusBarsPadding().navigationBarsPadding()
) )
Spacer( Spacer(
@ -186,11 +168,10 @@ class MainActivity : ComponentActivity() {
* and Track Downloads for all other releases like F-Droid, * 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 * for `Github Downloads` we will track Downloads using : https://tooomm.github.io/github-release-stats/?username=Shabinder&repository=SpotiFlyer
* */ * */
if (isGithubRelease) { checkIfLatestVersion() } if (isGithubRelease) {
if (preferenceManager.isAnalyticsEnabled && !isGithubRelease) { checkIfLatestVersion()
// Download/App Install Event for F-Droid builds
TrackHelper.track().download().with(tracker)
} }
// TODO Track Download Event
handleIntentFromExternalActivity() handleIntentFromExternalActivity()
initForegroundService() initForegroundService()
@ -260,7 +241,12 @@ class MainActivity : ComponentActivity() {
).show() ).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) super.onRequestPermissionsResult(requestCode, permissions, grantResults)
permissionGranted.value = checkPermissions() permissionGranted.value = checkPermissions()
} }
@ -270,15 +256,18 @@ class MainActivity : ComponentActivity() {
componentContext, componentContext,
dependencies = object : SpotiFlyerRoot.Dependencies { dependencies = object : SpotiFlyerRoot.Dependencies {
override val storeFactory = LoggingStoreFactory(DefaultStoreFactory) 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 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 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 actions = object : Actions {
override val platformActions = object : PlatformActions { override val platformActions = object : PlatformActions {
override val imageCacheDir: String = applicationContext.cacheDir.absolutePath + File.separator override val imageCacheDir: String =
applicationContext.cacheDir.absolutePath + File.separator
override val sharedPreferences = applicationContext.getSharedPreferences( override val sharedPreferences = applicationContext.getSharedPreferences(
SharedPreferencesKey, SharedPreferencesKey,
MODE_PRIVATE MODE_PRIVATE
@ -292,19 +281,26 @@ class MainActivity : ComponentActivity() {
} }
override fun sendTracksToService(array: List<TrackDetails>) { override fun sendTracksToService(array: List<TrackDetails>) {
if (foregroundService == null) initForegroundService() for (chunk in array.chunked(25)) {
foregroundService?.downloadAllTracks(array) if (foregroundService == null) initForegroundService()
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 queryActiveTracks() = this@MainActivity.queryActiveTracks()
override fun giveDonation() { 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() { 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 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 :) // hell yeah :)
preferenceManager.setDownloadDirectory(path) preferenceManager.setDownloadDirectory(path)
callBack(path) callBack(path)
showPopUpMessage(Strings.downloadDirectorySetTo("\n${dir.defaultDir()}")) showPopUpMessage(Strings.downloadDirectorySetTo("\n${fileManager.defaultDir()}"))
} else { } else {
showPopUpMessage(Strings.noWriteAccess("\n$path ")) showPopUpMessage(Strings.noWriteAccess("\n$path "))
} }
@ -433,6 +394,7 @@ class MainActivity : ComponentActivity() {
chooser.show() chooser.show()
} }
@Suppress("DEPRECATION")
@SuppressLint("ObsoleteSdkInt") @SuppressLint("ObsoleteSdkInt")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
@ -466,10 +428,10 @@ class MainActivity : ComponentActivity() {
val link = filterLinkRegex.find(string)?.value.toString() val link = filterLinkRegex.find(string)?.value.toString()
Log.i("Intent", link) Log.i("Intent", link)
lifecycleScope.launch { lifecycleScope.launch {
while (!this@MainActivity::root.isInitialized) { while (!this@MainActivity::rootComponent.isInitialized) {
delay(100) delay(100)
} }
if (methods.value.isInternetAvailable)callBacks.searchLink(link) if (methods.value.isInternetAvailable) callBacks.searchLink(link)
} }
} }
} }
@ -481,6 +443,16 @@ class MainActivity : ComponentActivity() {
unbindService() unbindService()
} }
override fun onStart() {
super.onStart()
analyticsManager.onStart()
}
override fun onStop() {
super.onStop()
analyticsManager.onStop()
}
companion object { companion object {
const val disableDozeCode = 1223 const val disableDozeCode = 1223
} }

View File

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

View File

@ -10,8 +10,28 @@ class TrackStatusFlowMap(
private val scope: CoroutineScope private val scope: CoroutineScope
) : HashMap<String, DownloadStatus>() { ) : HashMap<String, DownloadStatus>() {
override fun put(key: String, value: DownloadStatus): DownloadStatus? { override fun put(key: String, value: DownloadStatus): DownloadStatus? {
val res = super.put(key, value) synchronized(this) {
val res = super.put(key, value)
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) } scope.launch { statusFlow.emit(this@TrackStatusFlowMap) }
return res
} }
} }

View File

@ -27,9 +27,14 @@ allprojects {
// mavenLocal() // mavenLocal()
maven(url = "https://jitpack.io") maven(url = "https://jitpack.io")
maven(url = "https://maven.pkg.jetbrains.space/public/p/compose/dev") 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") 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 { tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
dependsOn(":common:data-models:generateI18n4kFiles") dependsOn(":common:data-models:generateI18n4kFiles")
kotlinOptions { jvmTarget = "1.8" } kotlinOptions { jvmTarget = "1.8" }

View File

@ -16,15 +16,20 @@
@file:Suppress("MayBeConstant", "SpellCheckingInspection") @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 { object Versions {
// App's Version (To be bumped at each update) // 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 // 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 // Code Formatting
const val ktLint = "10.1.0" const val ktLint = "10.1.0"
@ -41,38 +46,46 @@ object Versions {
const val mokoParcelize = "0.7.1" const val mokoParcelize = "0.7.1"
// Internet // 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 // Database
const val sqlDelight = "1.5.0" const val sqlDelight = "1.5.1"
const val sqliteJdbcDriver = "3.34.0" const val sqliteJdbcDriver = "3.34.0"
const val slf4j = "1.7.31" const val slf4j = "1.7.31"
// Internationalisation // Internationalisation
const val i18n4k = "0.1.2" const val i18n4k = "0.1.3"
// Android // Android
const val minSdkVersion = 21 const val minSdkVersion = 21
const val compileSdkVersion = 30 const val compileSdkVersion = 30
const val targetSdkVersion = 29 const val targetSdkVersion = 29
const val androidxLifecycle = "2.3.1" const val androidxLifecycle = "2.4.0-alpha03"
} }
object HostOS { object HostOS {
// Host OS Properties // Host OS Properties
private val hostOs = System.getProperty("os.name") private val hostOs = System.getProperty("os.name")
val isMingwX64 = hostOs.startsWith("Windows",true) val isMingwX64 = hostOs.startsWith("Windows", true)
val isMac = hostOs.startsWith("Mac",true) val isMac = hostOs.startsWith("Mac", true)
val isLinux = hostOs.startsWith("Linux",true) val isLinux = hostOs.startsWith("Linux", true)
} }
object MultiPlatformSettings { object MultiPlatformSettings {
const val dep = "com.russhwolf:multiplatform-settings-no-arg:0.7.7" 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 { object Koin {
val core = "io.insert-koin:koin-core:${Versions.koin}" val core = "io.insert-koin:koin-core:${Versions.koin}"
val test = "io.insert-koin:koin-test:${Versions.koin}" val test = "io.insert-koin:koin-test:${Versions.koin}"
@ -81,15 +94,15 @@ object Koin {
} }
object Androidx { object Androidx {
const val androidxActivity = "androidx.activity:activity-compose:1.3.0-beta02" const val androidxActivity = "androidx.activity:activity-compose:1.3.1"
const val core = "androidx.core:core-ktx:1.5.0" const val core = "androidx.core:core-ktx:1.6.0"
const val palette = "androidx.palette:palette-ktx:1.0.0" const val palette = "androidx.palette:palette-ktx:1.0.0"
const val coroutines = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutinesVersion}" const val coroutines = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutinesVersion}"
const val junit = "androidx.test.ext:junit:1.1.2" const val junit = "androidx.test.ext:junit:1.1.2"
const val expresso = "androidx.test.espresso:espresso-core:3.3.0" 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 { object KTLint {
@ -98,24 +111,28 @@ object KTLint {
object JetBrains { object JetBrains {
object Kotlin { 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 gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlinVersion}"
const val serialization = "org.jetbrains.kotlin:kotlin-serialization:${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 testCommon = "org.jetbrains.kotlin:kotlin-test-common:${Versions.kotlinVersion}"
const val testJunit = "org.jetbrains.kotlin:kotlin-test-junit:${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 { object Compose {
// __LATEST_COMPOSE_RELEASE_VERSION__ // __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" const val gradlePlugin = "org.jetbrains.compose:compose-gradle-plugin:$VERSION"
} }
} }
object Mosaic { object Mosaic {
const val gradlePlugin = "com.jakewharton.mosaic:mosaic-gradle-plugin:${Versions.mosaic}" const val gradlePlugin = "com.jakewharton.mosaic:mosaic-gradle-plugin:${Versions.mosaic}"
} }
object Decompose { 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 decompose = "com.arkivanov.decompose:decompose:$VERSION"
const val decomposeIosX64 = "com.arkivanov.decompose:decompose-iosx64:$VERSION" const val decomposeIosX64 = "com.arkivanov.decompose:decompose-iosx64:$VERSION"
const val decomposeIosArm64 = "com.arkivanov.decompose:decompose-iosarm64:$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 mp3agic = "com.mpatric:mp3agic:0.9.0"
const val jaudioTagger = "com.github.Shabinder:JAudioTagger-Android:1.0" const val jaudioTagger = "com.github.Shabinder:JAudioTagger-Android:1.0"
const val kermit = "co.touchlab:kermit:${Versions.kermit}" const val kermit = "co.touchlab:kermit:${Versions.kermit}"
object Android { object Android {
object Acra { // Self Hosted Analytics & Crashlytics (FOSS)
// Self Hosted Crashlytics (FOSS) val countly = "ly.count.android:sdk:20.11.8"
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"
val appUpdator = "com.github.amitbd1508:AppUpdater:4.1.0" val appUpdator = "com.github.amitbd1508:AppUpdater:4.1.0"
} }
object Desktop {
val countly = "ly.count.sdk:java:20.11.0"
}
} }
object Serialization { object Serialization {
@ -191,3 +207,10 @@ object SqlDelight {
val nativeDriverMacos = "com.squareup.sqldelight:native-driver-macosx64:${Versions.sqlDelight}" val nativeDriverMacos = "com.squareup.sqldelight:native-driver-macosx64:${Versions.sqlDelight}"
val jdbcDriver = "org.xerial:sqlite-jdbc:${Versions.sqliteJdbcDriver}" 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 { android {
compileSdkVersion(Versions.compileSdkVersion) compileSdk = Versions.compileSdkVersion
defaultConfig { defaultConfig {
minSdkVersion(Versions.minSdkVersion) minSdk = Versions.minSdkVersion
targetSdkVersion(Versions.targetSdkVersion) targetSdk = Versions.targetSdkVersion
} }
compileOptions { compileOptions {
@ -37,7 +37,6 @@ android {
sourceSets { sourceSets {
named("main") { named("main") {
manifest.srcFile("src/androidMain/AndroidManifest.xml") manifest.srcFile("src/androidMain/AndroidManifest.xml")
java.srcDirs("src/androidMain/kotlin")
res.srcDirs("src/androidMain/res") res.srcDirs("src/androidMain/res")
} }
} }

View File

@ -43,7 +43,7 @@ kotlin {
implementation(Extras.kermit) implementation(Extras.kermit)
implementation("dev.icerock.moko:parcelize:${Versions.mokoParcelize}") 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") @Suppress("DEPRECATION")
isForce = true isForce = true
} }
@ -51,7 +51,7 @@ kotlin {
} }
named("androidMain") { named("androidMain") {
dependencies { dependencies {
implementation("androidx.appcompat:appcompat:1.3.0") implementation(Androidx.androidxActivity)
implementation(Androidx.core) implementation(Androidx.core)
} }
} }

View File

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

View File

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

View File

@ -34,6 +34,7 @@ kotlin {
implementation(project(":common:main")) implementation(project(":common:main"))
implementation(project(":common:list")) implementation(project(":common:list"))
implementation(project(":common:preference")) implementation(project(":common:preference"))
implementation(project(":common:core-components"))
implementation(project(":common:database")) implementation(project(":common:database"))
implementation(project(":common:data-models")) implementation(project(":common:data-models"))
implementation(project(":common:dependency-injection")) 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.Modifier
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import com.shabinder.common.di.Picture import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.di.dispatcherIO import com.shabinder.common.models.dispatcherIO
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@Composable @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.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import com.shabinder.common.database.R import com.shabinder.common.models.R
actual fun montserratFont() = FontFamily( actual fun montserratFont() = FontFamily(
Font(R.font.montserrat_light, FontWeight.Light), Font(R.font.montserrat_light, FontWeight.Light),
@ -12,6 +12,6 @@ actual fun montserratFont() = FontFamily(
Font(R.font.montserrat_semibold, FontWeight.SemiBold), Font(R.font.montserrat_semibold, FontWeight.SemiBold),
) )
actual fun pristineFont() = FontFamily( actual fun pristineFont(): FontFamily = FontFamily(
Font(R.font.pristine_script, FontWeight.Bold) 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.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.shabinder.common.di.Picture import com.shabinder.common.core_components.picture.Picture
@Composable @Composable
expect fun ImageLoad( 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState 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.list.SpotiFlyerList
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.TrackDetails 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState 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
import com.shabinder.common.main.SpotiFlyerMain.HomeCategory import com.shabinder.common.main.SpotiFlyerMain.HomeCategory
import com.shabinder.common.models.DownloadRecord import com.shabinder.common.models.DownloadRecord

View File

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

View File

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

View File

@ -2,17 +2,12 @@ package com.shabinder.common.uikit
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
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.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import com.shabinder.common.di.Picture import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.di.dispatcherIO import com.shabinder.common.models.dispatcherIO
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@Composable @Composable
@ -31,6 +26,11 @@ actual fun ImageLoad(
} }
Crossfade(pic) { 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/>. * * 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
import android.content.Context.CONNECTIVITY_SERVICE import android.content.Context.CONNECTIVITY_SERVICE
@ -24,6 +24,7 @@ import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
import android.net.NetworkRequest import android.net.NetworkRequest
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.shabinder.common.core_components.utils.isInternetAccessible
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch 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/>. * * 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 android.util.Log
import com.mpatric.mp3agic.ID3v1Tag import com.mpatric.mp3agic.ID3v1Tag
import com.mpatric.mp3agic.ID3v24Tag import com.mpatric.mp3agic.ID3v24Tag
import com.mpatric.mp3agic.Mp3File 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.DownloadResult
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
@ -48,7 +49,7 @@ fun Mp3File.setId3v1Tags(track: TrackDetails): Mp3File {
} }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) { suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails, outputFilePath: String? = null) {
val id3v2Tag = ID3v24Tag().apply { val id3v2Tag = ID3v24Tag().apply {
albumArtist = track.albumArtists.joinToString(", ") albumArtist = track.albumArtists.joinToString(", ")
artist = track.artists.joinToString(", ") artist = track.artists.joinToString(", ")
@ -71,7 +72,7 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
fis.close() fis.close()
id3v2Tag.setAlbumImage(bytesArray, "image/jpeg") id3v2Tag.setAlbumImage(bytesArray, "image/jpeg")
this.id3v2Tag = id3v2Tag this.id3v2Tag = id3v2Tag
saveFile(track.outputFilePath) saveFile(outputFilePath ?: track.outputFilePath)
} catch (e: java.io.FileNotFoundException) { } catch (e: java.io.FileNotFoundException) {
Log.e("Error", "Couldn't Write Cached Mp3 Album Art, Downloading And Trying Again, error: ${e.message}") Log.e("Error", "Couldn't Write Cached Mp3 Album Art, Downloading And Trying Again, error: ${e.message}")
try { try {
@ -83,7 +84,7 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
is DownloadResult.Success -> { is DownloadResult.Success -> {
id3v2Tag.setAlbumImage(it.byteArray, "image/jpeg") id3v2Tag.setAlbumImage(it.byteArray, "image/jpeg")
this.id3v2Tag = id3v2Tag this.id3v2Tag = id3v2Tag
saveFile(track.outputFilePath) saveFile(outputFilePath ?: track.outputFilePath)
} }
is DownloadResult.Progress -> {} // Nothing for Now , no progress bar to show 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) { fun Mp3File.saveFile(filePath: String) {
save(filePath.substringBeforeLast('.') + ".new.mp3") save(filePath.substringBeforeLast('.') + ".tagged.mp3")
val m4aFile = File(filePath)
m4aFile.delete() val oldFile = File(filePath)
val newFile = File((filePath.substringBeforeLast('.') + ".new.mp3")) oldFile.delete()
val newFile = File((filePath.substringBeforeLast('.') + ".tagged.mp3"))
newFile.renameTo(File(filePath.substringBeforeLast('.') + ".mp3")) newFile.renameTo(File(filePath.substringBeforeLast('.') + ".mp3"))
} }

View File

@ -20,9 +20,6 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
actual data class Picture(
var image: ImageBitmap?
)
fun getMemoryEfficientBitmap( fun getMemoryEfficientBitmap(
input: ByteArray, input: ByteArray,
reqWidth: Int, 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/>. * * 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 co.touchlab.kermit.Kermit
import com.shabinder.common.database.SpotiFlyerDatabase import com.shabinder.common.core_components.media_converter.MediaConverter
import com.shabinder.common.di.preference.PreferenceManager import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.di.utils.removeIllegalChars 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.DownloadResult
import com.shabinder.common.models.TrackDetails 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 com.shabinder.database.Database
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.statement.* import io.ktor.client.statement.*
import io.ktor.http.* import io.ktor.http.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import org.koin.core.module.Module
import kotlin.math.roundToInt import kotlin.math.roundToInt
expect class Dir( internal expect fun fileManagerModule(): Module
logger: Kermit,
preferenceManager: PreferenceManager, interface FileManager {
spotiFlyerDatabase: SpotiFlyerDatabase,
) { val logger: Kermit
val preferenceManager: PreferenceManager
val mediaConverter: MediaConverter
val db: Database? val db: Database?
fun isPresent(path: String): Boolean fun isPresent(path: String): Boolean
fun fileSeparator(): String fun fileSeparator(): String
fun defaultDir(): String fun defaultDir(): String
fun imageCacheDir(): String fun imageCacheDir(): String
fun createDirectory(dirPath: String) fun createDirectory(dirPath: String)
suspend fun cacheImage(image: Any, path: String) // in Android = ImageBitmap, Desktop = BufferedImage suspend fun cacheImage(image: Any, path: String) // in Android = ImageBitmap, Desktop = BufferedImage
suspend fun loadImage(url: String, reqWidth: Int = 150, reqHeight: Int = 150): Picture suspend fun loadImage(url: String, reqWidth: Int = 150, reqHeight: Int = 150): Picture
suspend fun clearCache() suspend fun clearCache()
suspend fun saveFileWithMetadata(mp3ByteArray: ByteArray, trackDetails: TrackDetails, postProcess: (track: TrackDetails) -> Unit = {})
suspend fun saveFileWithMetadata(
mp3ByteArray: ByteArray,
trackDetails: TrackDetails,
postProcess: (track: TrackDetails) -> Unit = {}
): SuspendableEvent<String, Throwable>
fun addToLibrary(path: String) fun addToLibrary(path: String)
} }
/* /*
* Call this function at startup! * Call this function at startup!
* */ * */
fun Dir.createDirectories() { fun FileManager.createDirectories() {
try { try {
createDirectory(defaultDir()) createDirectory(defaultDir())
createDirectory(imageCacheDir()) createDirectory(imageCacheDir())
@ -59,13 +80,21 @@ fun Dir.createDirectories() {
createDirectory(defaultDir() + "Albums/") createDirectory(defaultDir() + "Albums/")
createDirectory(defaultDir() + "Playlists/") createDirectory(defaultDir() + "Playlists/")
createDirectory(defaultDir() + "YT_Downloads/") 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() + defaultDir + removeIllegalChars(type) + this.fileSeparator() +
if (subFolder.isEmpty())"" else { removeIllegalChars(subFolder) + this.fileSeparator() } + if (subFolder.isEmpty()) "" else {
removeIllegalChars(itemName) + extension removeIllegalChars(subFolder) + this.fileSeparator()
} +
removeIllegalChars(itemName) + extension
/*DIR Specific Operation End*/ /*DIR Specific Operation End*/
fun getNameURL(url: String): String { fun getNameURL(url: String): String {
@ -74,28 +103,26 @@ fun getNameURL(url: String): String {
suspend fun downloadFile(url: String): Flow<DownloadResult> { suspend fun downloadFile(url: String): Flow<DownloadResult> {
return flow { return flow {
try { val client = createHttpClient()
val client = createHttpClient() val response = client.get<HttpStatement>(url).execute()
val response = client.get<HttpStatement>(url).execute() val data = ByteArray(response.contentLength()!!.toInt())
val data = ByteArray(response.contentLength()!!.toInt()) var offset = 0
var offset = 0 do {
do { // Set Length optimally, after how many kb you want a progress update, now it 0.25mb
// Set Length optimally, after how many kb you want a progress update, now it 0.25mb val currentRead = response.content.readAvailable(data, offset, 2_50_000)
val currentRead = response.content.readAvailable(data, offset, 2_50_000) offset += currentRead
offset += currentRead val progress = (offset * 100f / data.size).roundToInt()
val progress = (offset * 100f / data.size).roundToInt() emit(DownloadResult.Progress(progress))
emit(DownloadResult.Progress(progress)) } while (currentRead > 0)
} while (currentRead > 0) if (response.status.isSuccess()) {
if (response.status.isSuccess()) { emit(DownloadResult.Success(data))
emit(DownloadResult.Success(data)) } else {
} else { emit(DownloadResult.Error("File not downloaded"))
emit(DownloadResult.Error("File not downloaded"))
}
client.close()
} catch (e: Exception) {
e.printStackTrace()
emit(DownloadResult.Error(e.message ?: "File not downloaded"))
} }
client.close()
}.catch { e ->
e.printStackTrace()
emit(DownloadResult.Error(e.message ?: "File not downloaded"))
} }
} }

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/>. * * 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: // Dependencies:
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9-native-mt") // implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9-native-mt")
// implementation("org.jetbrains.kotlinx:atomicfu:0.14.4") // implementation("org.jetbrains.kotlinx:atomicfu:0.14.4")
// Gist: https://gist.github.com/fluidsonic/ba32de21c156bbe8424c8d5fc20dcd8e // 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 io.ktor.utils.io.core.*
import kotlinx.atomicfu.atomic import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
class ParallelExecutor( interface ParallelProcessor {
parentContext: CoroutineContext = dispatcherIO,
) : Closeable {
private val concurrentOperationLimit = atomic(4) val parallelExecutor: ParallelExecutor
private val coroutineContext = parentContext + Job()
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 var isClosed = atomic(false)
private val killQueue = Channel<Unit>(Channel.UNLIMITED) private var killQueue = Channel<Unit>(Channel.UNLIMITED)
private val operationQueue = Channel<Operation<*>>(Channel.RENDEZVOUS) private var operationQueue = Channel<Operation<*>>(Channel.RENDEZVOUS)
private var concurrentOperationLimit = atomic(concurrentOperationLimit)
init { init {
startOrStopProcessors(expectedCount = concurrentOperationLimit.value, actualCount = 0) startOrStopProcessors(expectedCount = this.concurrentOperationLimit.value, actualCount = 0)
} }
override fun close() { override fun close() {
@ -58,9 +80,29 @@ class ParallelExecutor(
killQueue.close(cause) killQueue.close(cause)
operationQueue.close(cause) operationQueue.close(cause)
service.cancel(cause)
coroutineContext.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 { private fun CoroutineScope.launchProcessor() = launch {
while (true) { while (true) {
val operation = select<Operation<*>?> { 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) { withContext(coroutineContext) {
val operation = Operation(block) val operation = Operation(block)
operationQueue.send(operation) operationQueue.send(operation)
@ -80,6 +122,15 @@ class ParallelExecutor(
operation.result.await() 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. // TODO This launches all coroutines in advance even if they're never needed. Find a lazy way to do this.
fun setConcurrentOperationLimit(limit: Int) { fun setConcurrentOperationLimit(limit: Int) {
require(limit >= 1) { "'limit' must be greater than zero: $limit" } require(limit >= 1) { "'limit' must be greater than zero: $limit" }
@ -89,6 +140,7 @@ class ParallelExecutor(
} }
private fun startOrStopProcessors(expectedCount: Int, actualCount: Int) { private fun startOrStopProcessors(expectedCount: Int, actualCount: Int) {
if (!service.isActive) service = SupervisorJob()
if (expectedCount == actualCount) if (expectedCount == actualCount)
return return
@ -100,9 +152,7 @@ class ParallelExecutor(
change -= 1 change -= 1
if (change > 0) if (change > 0)
with(CoroutineScope(coroutineContext)) { repeat(change) { launchProcessor() }
repeat(change) { launchProcessor() }
}
else else
repeat(-change) { killQueue.trySend(Unit).isSuccess } 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.russhwolf.settings.Settings
import com.shabinder.common.core_components.analytics.AnalyticsManager
import com.shabinder.common.models.AudioQuality import com.shabinder.common.models.AudioQuality
class PreferenceManager(settings: Settings) : Settings by settings { class PreferenceManager(
settings: Settings,
) : Settings by settings {
companion object { companion object {
const val DIR_KEY = "downloadDir" const val DIR_KEY = "downloadDir"
@ -13,9 +16,16 @@ class PreferenceManager(settings: Settings) : Settings by settings {
const val PREFERRED_AUDIO_QUALITY = "preferredAudioQuality" const val PREFERRED_AUDIO_QUALITY = "preferredAudioQuality"
} }
lateinit var analyticsManager: AnalyticsManager
/* ANALYTICS */ /* ANALYTICS */
val isAnalyticsEnabled get() = getBooleanOrNull(ANALYTICS_KEY) ?: false 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 */ /* DOWNLOAD DIRECTORY */
val downloadDir get() = getStringOrNull(DIR_KEY) 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.Value
import com.arkivanov.decompose.value.ValueObserver 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/>. * * 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.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import co.touchlab.kermit.Kermit import co.touchlab.kermit.Kermit
import com.github.kokorin.jaffree.JaffreeException
import com.mpatric.mp3agic.InvalidDataException
import com.mpatric.mp3agic.Mp3File 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.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.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 com.shabinder.database.Database
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.skija.Image import org.jetbrains.skija.Image
import org.koin.dsl.bind
import org.koin.dsl.module
import java.awt.image.BufferedImage import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
@ -38,33 +52,45 @@ import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import javax.imageio.ImageIO import javax.imageio.ImageIO
actual class Dir actual constructor( internal actual fun fileManagerModule() = module {
private val logger: Kermit, single { DesktopFileManager(get(), get(), get(), get()) } bind FileManager::class
private val preferenceManager: PreferenceManager, }
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, spotiFlyerDatabase: SpotiFlyerDatabase,
) { ) : FileManager {
init { init {
createDirectories() 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() fileSeparator() + "SpotiFlyer/.images" + fileSeparator()
private val defaultBaseDir = System.getProperty("user.home") 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() "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) val yourAppDir = File(dirPath)
if (!yourAppDir.exists() && !yourAppDir.isDirectory) { // create empty directory 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!" } logger.e { "Unable to create Dir: $dirPath!" }
} }
} else { } else {
@ -72,11 +98,11 @@ actual class Dir actual constructor(
} }
} }
actual suspend fun clearCache() { override suspend fun clearCache() {
File(imageCacheDir()).deleteRecursively() File(imageCacheDir()).deleteRecursively()
} }
actual suspend fun cacheImage(image: Any, path: String) { override suspend fun cacheImage(image: Any, path: String): Unit = withContext(dispatcherIO) {
try { try {
(image as? BufferedImage)?.let { (image as? BufferedImage)?.let {
ImageIO.write(it, "jpeg", File(path)) ImageIO.write(it, "jpeg", File(path))
@ -87,11 +113,11 @@ actual class Dir actual constructor(
} }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
actual suspend fun saveFileWithMetadata( override suspend fun saveFileWithMetadata(
mp3ByteArray: ByteArray, mp3ByteArray: ByteArray,
trackDetails: TrackDetails, trackDetails: TrackDetails,
postProcess: (track: TrackDetails) -> Unit postProcess: (track: TrackDetails) -> Unit
) { ) = withContext(dispatcherIO) {
val songFile = File(trackDetails.outputFilePath) val songFile = File(trackDetails.outputFilePath)
try { try {
/* /*
@ -103,61 +129,52 @@ actual class Dir actual constructor(
} }
if (mp3ByteArray.isNotEmpty()) songFile.writeBytes(mp3ByteArray) if (mp3ByteArray.isNotEmpty()) 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"
when (trackDetails.outputFilePath.substringAfterLast('.')) { val conversionResult = mediaConverter.convertAudioFile(
".mp3" -> { inputFilePath = songFile.absolutePath,
Mp3File(File(songFile.absolutePath)) outputFilePath = convertedFilePath,
.removeAllTags() trackDetails.audioQuality
.setId3v1Tags(trackDetails) )
.setId3v2TagsAndSaveFile(trackDetails)
addToLibrary(songFile.absolutePath) conversionResult.map { outputFilePath ->
} Mp3File(File(outputFilePath))
".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() .removeAllTags()
.setId3v1Tags(trackDetails) .setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails) .setId3v2TagsAndSaveFile(trackDetails, trackDetails.outputFilePath)
addToLibrary(songFile.absolutePath)
} catch (e: Exception) { e.printStackTrace() } addToLibrary(trackDetails.outputFilePath)
} }.fold(
} success = {},
} catch (e: Exception) { failure = {
withContext(Dispatchers.Main) { throw it
// Toast.makeText(appContext,"Could Not Create File:\n${songFile.absolutePath}",Toast.LENGTH_SHORT).show() }
)
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() if (songFile.exists()) songFile.delete()
logger.e { "${songFile.absolutePath} could not be created" } 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) val cachePath = imageCacheDir() + getNameURL(url)
var picture: ImageBitmap? = loadCachedImage(cachePath, reqWidth, reqHeight) var picture: ImageBitmap? = loadCachedImage(cachePath, reqWidth, reqHeight)
if (picture == null) picture = freshImage(url, reqWidth, reqHeight) if (picture == null) picture = freshImage(url, reqWidth, reqHeight)
@ -173,6 +190,7 @@ actual class Dir actual constructor(
} }
} }
@OptIn(DelicateCoroutinesApi::class)
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
private suspend fun freshImage(url: String, reqWidth: Int, reqHeight: Int): ImageBitmap? { private suspend fun freshImage(url: String, reqWidth: Int, reqHeight: Int): ImageBitmap? {
return withContext(Dispatchers.IO) { 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( 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/>. * * 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.ID3v1Tag
import com.mpatric.mp3agic.ID3v24Tag import com.mpatric.mp3agic.ID3v24Tag
import com.mpatric.mp3agic.Mp3File 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.DownloadResult
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
@ -47,16 +48,22 @@ fun Mp3File.setId3v1Tags(track: TrackDetails): Mp3File {
return this return this
} }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) { suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails, outputFilePath: String? = null) {
val id3v2Tag = ID3v24Tag().apply { val id3v2Tag = ID3v24Tag().apply {
artist = track.artists.joinToString(",") albumArtist = track.albumArtists.joinToString(", ")
artist = track.artists.joinToString(", ")
title = track.title title = track.title
album = track.albumName album = track.albumName
year = track.year 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 url = track.trackUrl
if (track.trackNumber != null)
this.track = track.trackNumber.toString()
} }
try { try {
val art = File(track.albumArtPath) val art = File(track.albumArtPath)
@ -66,7 +73,7 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
fis.close() fis.close()
id3v2Tag.setAlbumImage(bytesArray, "image/jpeg") id3v2Tag.setAlbumImage(bytesArray, "image/jpeg")
this.id3v2Tag = id3v2Tag this.id3v2Tag = id3v2Tag
saveFile(track.outputFilePath) saveFile(outputFilePath ?: track.outputFilePath)
} catch (e: java.io.FileNotFoundException) { } catch (e: java.io.FileNotFoundException) {
try { try {
// Image Still Not Downloaded! // Image Still Not Downloaded!
@ -77,21 +84,23 @@ suspend fun Mp3File.setId3v2TagsAndSaveFile(track: TrackDetails) {
is DownloadResult.Success -> { is DownloadResult.Success -> {
id3v2Tag.setAlbumImage(it.byteArray, "image/jpeg") id3v2Tag.setAlbumImage(it.byteArray, "image/jpeg")
this.id3v2Tag = id3v2Tag this.id3v2Tag = id3v2Tag
saveFile(track.outputFilePath) saveFile(outputFilePath ?: track.outputFilePath)
} }
is DownloadResult.Progress -> {} // Nothing for Now , no progress bar to show is DownloadResult.Progress -> {} // Nothing for Now , no progress bar to show
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
// log("Error", "Couldn't Write Mp3 Album Art, error: ${e.stackTrace}") e.printStackTrace()
} }
} }
} }
fun Mp3File.saveFile(filePath: String) { fun Mp3File.saveFile(filePath: String) {
save(filePath.substringBeforeLast('.') + ".new.mp3") save(filePath.substringBeforeLast('.') + ".tagged.mp3")
val m4aFile = File(filePath)
m4aFile.delete() val oldFile = File(filePath)
val newFile = File((filePath.substringBeforeLast('.') + ".new.mp3")) oldFile.delete()
val newFile = File((filePath.substringBeforeLast('.') + ".tagged.mp3"))
newFile.renameTo(File(filePath.substringBeforeLast('.') + ".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:JsModule("file-saver")
@file:JsNonModule @file:JsNonModule
package com.shabinder.common.di package com.shabinder.common.core_components
import org.w3c.files.Blob import org.w3c.files.Blob

View File

@ -14,7 +14,7 @@
* * along with this program. If not, see <https://www.gnu.org/licenses/>. * * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package com.shabinder.common.di package com.shabinder.common.core_components
import org.khronos.webgl.ArrayBuffer import org.khronos.webgl.ArrayBuffer
import org.w3c.files.Blob 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) 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"), KBPS160("160"),
KBPS192("192"), KBPS192("192"),
KBPS256("256"), KBPS256("256"),
KBPS320("320"); KBPS320("320"),
UNKNOWN("-1");
companion object { companion object {
fun getQuality(kbps: String): AudioQuality { fun getQuality(kbps: String): AudioQuality {
@ -15,6 +16,7 @@ enum class AudioQuality(val kbps: String) {
"192" -> KBPS192 "192" -> KBPS192
"256" -> KBPS256 "256" -> KBPS256
"320" -> KBPS320 "320" -> KBPS320
"-1" -> UNKNOWN
else -> KBPS160 // Use 160 as baseline 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 progress: Int = 2,
val downloadLink: String? = null, val downloadLink: String? = null,
val downloaded: DownloadStatus = DownloadStatus.NotDownloaded, val downloaded: DownloadStatus = DownloadStatus.NotDownloaded,
var audioQuality: AudioQuality = AudioQuality.KBPS192,
var outputFilePath: String, // UriString in Android var outputFilePath: String, // UriString in Android
var videoID: String? = null, var videoID: String? = null,
) : Parcelable ) : Parcelable {
val outputMp3Path get() = outputFilePath.substringBeforeLast(".") + ".mp3"
}
@Serializable @Serializable
sealed class DownloadStatus : Parcelable { sealed class DownloadStatus : Parcelable {

View File

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

View File

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

View File

@ -1,5 +1,6 @@
package com.shabinder.common.models.saavn package com.shabinder.common.models.saavn
import com.shabinder.common.models.AudioQuality
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -43,4 +44,6 @@ data class SaavnSong @OptIn(ExperimentalSerializationApi::class) constructor(
val vlink: String? = null, val vlink: String? = null,
val year: String, val year: String,
var downloaded: DownloadStatus = DownloadStatus.NotDownloaded 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 * JSON UTILS

View File

@ -1,20 +1,4 @@
/* package com.shabinder.common.utils
* * 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
import io.github.shabinder.TargetPlatforms import io.github.shabinder.TargetPlatforms
import io.github.shabinder.activePlatform import io.github.shabinder.activePlatform
@ -22,7 +6,7 @@ import kotlinx.serialization.json.Json
import kotlin.native.concurrent.ThreadLocal import kotlin.native.concurrent.ThreadLocal
@ThreadLocal @ThreadLocal
val json by lazy { val globalJson by lazy {
Json { Json {
isLenient = true isLenient = true
ignoreUnknownKeys = 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 package com.shabinder.common.models
actual interface PlatformActions actual interface PlatformActions
actual val StubPlatformActions = object : 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 { dependencies {
implementation(project(":common:data-models")) implementation(project(":common:data-models"))
implementation(project(":common:database")) implementation(project(":common:database"))
implementation("org.jetbrains.kotlinx:atomicfu:0.16.2") implementation(project(":common:providers"))
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.2.1") implementation(project(":common:core-components"))
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"))
} }
} }
} }

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 package com.shabinder.common.di
import co.touchlab.kermit.Kermit import com.shabinder.common.core_components.coreComponentModules
import com.russhwolf.settings.Settings
import com.shabinder.common.database.databaseModule import com.shabinder.common.database.databaseModule
import com.shabinder.common.database.getLogger import com.shabinder.common.providers.providersModule
import com.shabinder.common.di.preference.PreferenceManager import org.koin.core.KoinApplication
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 org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.core.module.Module
import org.koin.dsl.KoinAppDeclaration 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 = {}) = fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclaration = {}) =
startKoin { startKoin {
appDeclaration() appDeclaration()
modules( modules(
commonModule(enableNetworkLogs = enableNetworkLogs), coreComponentModules(enableNetworkLogs),
providersModule(), listOf(
databaseModule() providersModule(enableNetworkLogs),
databaseModule(),
)
) )
} }
// Called by IOS // Called by IOS
fun initKoin() = initKoin(enableNetworkLogs = false) { } fun initKoin() = initKoin(enableNetworkLogs = false) { }
fun commonModule(enableNetworkLogs: Boolean) = module { private fun KoinApplication.modules(vararg moduleLists: List<Module>): KoinApplication {
single { createHttpClient(enableNetworkLogs = enableNetworkLogs) } return modules(moduleLists.toList().flatten())
single { Dir(get(), get(), get()) }
single { Settings() }
single { PreferenceManager(get()) }
single { Kermit(getLogger()) }
single { TokenStore(get(), get()) }
} }
@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:dependency-injection"))
implementation(project(":common:data-models")) implementation(project(":common:data-models"))
implementation(project(":common:database")) implementation(project(":common:database"))
implementation(project(":common:providers"))
implementation(project(":common:core-components"))
implementation(SqlDelight.coroutineExtensions) implementation(SqlDelight.coroutineExtensions)
} }
} }

View File

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

View File

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

View File

@ -16,8 +16,8 @@
package com.shabinder.common.list.store package com.shabinder.common.list.store
import com.arkivanov.decompose.instancekeeper.InstanceKeeper import com.arkivanov.essenty.instancekeeper.InstanceKeeper
import com.arkivanov.decompose.instancekeeper.getOrCreate import com.arkivanov.essenty.instancekeeper.getOrCreate
import com.arkivanov.mvikotlin.core.store.Store import com.arkivanov.mvikotlin.core.store.Store
fun <T : Store<*, *, *>> InstanceKeeper.getStore(key: Any, factory: () -> T): T = 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.Reducer
import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper
import com.arkivanov.mvikotlin.core.store.Store import com.arkivanov.mvikotlin.core.store.Store
import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor
import com.shabinder.common.di.Dir import com.shabinder.common.list.SpotiFlyerList
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.State import com.shabinder.common.list.SpotiFlyerList.State
import com.shabinder.common.list.store.SpotiFlyerListStore.Intent import com.shabinder.common.list.store.SpotiFlyerListStore.Intent
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.PlatformQueryResult import com.shabinder.common.models.PlatformQueryResult
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.methods import com.shabinder.common.models.methods
import kotlinx.coroutines.flow.MutableSharedFlow import com.shabinder.common.providers.downloadTracks
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
internal class SpotiFlyerListStoreProvider( internal class SpotiFlyerListStoreProvider(dependencies: SpotiFlyerList.Dependencies) :
private val dir: Dir, SpotiFlyerList.Dependencies by dependencies {
private val preferenceManager: PreferenceManager,
private val storeFactory: StoreFactory,
private val fetchQuery: FetchPlatformQueryResult,
private val link: String,
private val downloadProgressFlow: MutableSharedFlow<HashMap<String, DownloadStatus>>
) {
fun provide(): SpotiFlyerListStore = fun provide(): SpotiFlyerListStore =
object : object :
SpotiFlyerListStore, SpotiFlyerListStore,
@ -66,7 +56,7 @@ internal class SpotiFlyerListStoreProvider(
override suspend fun executeAction(action: Unit, getState: () -> State) { override suspend fun executeAction(action: Unit, getState: () -> State) {
executeIntent(Intent.SearchLink(link), getState) 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 // 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") fetchQuery.logger.d(message = { "Database List Last ID: $it" }, tag = "Database Last ID")
val offset = preferenceManager.getDonationOffset val offset = preferenceManager.getDonationOffset
@ -92,7 +82,12 @@ internal class SpotiFlyerListStoreProvider(
resp.fold( resp.fold(
success = { result -> success = { result ->
result.trackList = result.trackList.toMutableList() 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) executeIntent(Intent.RefreshTracksStatuses, getState)
}, },
failure = { failure = {
@ -103,25 +98,32 @@ internal class SpotiFlyerListStoreProvider(
is Intent.StartDownloadAll -> { is Intent.StartDownloadAll -> {
val list = intent.trackList.map { 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) return@map it.copy(downloaded = DownloadStatus.Queued)
it it
} }
dispatch(Result.UpdateTrackList(list.updateTracksStatuses(downloadProgressFlow.replayCache.getOrElse(0) { hashMapOf() }))) dispatch(
Result.UpdateTrackList(
list.updateTracksStatuses(
downloadProgressFlow.replayCache.getOrElse(
0
) { hashMapOf() })
)
)
val finalList = val finalList = intent.trackList.filter { it.downloaded == DownloadStatus.NotDownloaded }
intent.trackList.filter { it.downloaded == DownloadStatus.NotDownloaded } if (finalList.isEmpty()) methods.value.showPopUpMessage("All Songs are Processed")
if (finalList.isNullOrEmpty()) methods.value.showPopUpMessage("All Songs are Processed") else downloadTracks(finalList, fetchQuery, fileManager)
else downloadTracks(finalList, fetchQuery, dir)
} }
is Intent.StartDownload -> { is Intent.StartDownload -> {
dispatch(Result.UpdateTrackItem(intent.track.copy(downloaded = DownloadStatus.Queued))) 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() is Intent.RefreshTracksStatuses -> methods.value.queryActiveTracks()
} }
} }
} }
private object ReducerImpl : Reducer<State, Result> { private object ReducerImpl : Reducer<State, Result> {
override fun State.reduce(result: Result): State = override fun State.reduce(result: Result): State =
when (result) { when (result) {
@ -140,6 +142,7 @@ internal class SpotiFlyerListStoreProvider(
return this return this
} }
} }
private fun List<TrackDetails>.updateTracksStatuses(map: HashMap<String, DownloadStatus>): List<TrackDetails> { private fun List<TrackDetails>.updateTracksStatuses(map: HashMap<String, DownloadStatus>): List<TrackDetails> {
val titleList = this.map { it.title } val titleList = this.map { it.title }
val updatedList = mutableListOf<TrackDetails>().also { it.addAll(this) } val updatedList = mutableListOf<TrackDetails>().also { it.addAll(this) }
@ -147,7 +150,11 @@ internal class SpotiFlyerListStoreProvider(
for (newTrack in map) { for (newTrack in map) {
titleList.indexOf(newTrack.key).let { position -> titleList.indexOf(newTrack.key).let { position ->
if (position != -1) { 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 updatedList[position] = updatedTrack
// logger.d("$position) ${updatedTrack.downloaded} - ${updatedTrack.title}","List Store Track Update") // 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:dependency-injection"))
implementation(project(":common:data-models")) implementation(project(":common:data-models"))
implementation(project(":common:database")) implementation(project(":common:database"))
implementation(project(":common:providers"))
implementation(project(":common:core-components"))
implementation(SqlDelight.coroutineExtensions) implementation(SqlDelight.coroutineExtensions)
} }
} }

View File

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

View File

@ -18,16 +18,13 @@ package com.shabinder.common.main.integration
import co.touchlab.stately.ensureNeverFrozen import co.touchlab.stately.ensureNeverFrozen
import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.lifecycle.doOnResume
import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.Value
import com.arkivanov.essenty.lifecycle.doOnResume
import com.shabinder.common.caching.Cache import com.shabinder.common.caching.Cache
import com.shabinder.common.di.Picture import com.shabinder.common.core_components.picture.Picture
import com.shabinder.common.di.utils.asValue import com.shabinder.common.core_components.utils.asValue
import com.shabinder.common.main.SpotiFlyerMain import com.shabinder.common.main.SpotiFlyerMain
import com.shabinder.common.main.SpotiFlyerMain.Dependencies import com.shabinder.common.main.SpotiFlyerMain.*
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.store.SpotiFlyerMainStore.Intent import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent
import com.shabinder.common.main.store.SpotiFlyerMainStoreProvider import com.shabinder.common.main.store.SpotiFlyerMainStoreProvider
import com.shabinder.common.main.store.getStore import com.shabinder.common.main.store.getStore
@ -47,12 +44,7 @@ internal class SpotiFlyerMainImpl(
private val store = private val store =
instanceKeeper.getStore { instanceKeeper.getStore {
SpotiFlyerMainStoreProvider( SpotiFlyerMainStoreProvider(dependencies).provide()
preferenceManager = preferenceManager,
storeFactory = storeFactory,
database = database,
dir = dir
).provide()
} }
private val cache = Cache.Builder private val cache = Cache.Builder
@ -83,7 +75,7 @@ internal class SpotiFlyerMainImpl(
override suspend fun loadImage(url: String): Picture { override suspend fun loadImage(url: String): Picture {
return cache.get(url) { 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 package com.shabinder.common.main.store
import com.arkivanov.decompose.instancekeeper.InstanceKeeper import com.arkivanov.essenty.instancekeeper.InstanceKeeper
import com.arkivanov.decompose.instancekeeper.getOrCreate import com.arkivanov.essenty.instancekeeper.getOrCreate
import com.arkivanov.mvikotlin.core.store.Store import com.arkivanov.mvikotlin.core.store.Store
fun <T : Store<*, *, *>> InstanceKeeper.getStore(key: Any, factory: () -> T): T = 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.Reducer
import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper
import com.arkivanov.mvikotlin.core.store.Store import com.arkivanov.mvikotlin.core.store.Store
import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.arkivanov.mvikotlin.extensions.coroutines.SuspendExecutor 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
import com.shabinder.common.main.SpotiFlyerMain.State import com.shabinder.common.main.SpotiFlyerMain.State
import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent
import com.shabinder.common.models.DownloadRecord import com.shabinder.common.models.DownloadRecord
import com.shabinder.common.models.methods import com.shabinder.common.models.methods
import com.shabinder.database.Database
import com.squareup.sqldelight.runtime.coroutines.asFlow import com.squareup.sqldelight.runtime.coroutines.asFlow
import com.squareup.sqldelight.runtime.coroutines.mapToList import com.squareup.sqldelight.runtime.coroutines.mapToList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -36,12 +32,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
internal class SpotiFlyerMainStoreProvider( internal class SpotiFlyerMainStoreProvider(dependencies: SpotiFlyerMain.Dependencies): SpotiFlyerMain.Dependencies by dependencies {
private val storeFactory: StoreFactory,
private val preferenceManager: PreferenceManager,
private val dir: Dir,
database: Database?
) {
fun provide(): SpotiFlyerMainStore = fun provide(): SpotiFlyerMainStore =
object : object :

View File

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

View File

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

View File

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