From 95ddc8b2941e4ab94285fa5771a775bc99fb7395 Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Tue, 3 Aug 2021 15:49:21 +0200 Subject: [PATCH 01/22] add kotlin support in gradle --- app/build.gradle | 5 +++++ build.gradle | 1 + 2 files changed, 6 insertions(+) diff --git a/app/build.gradle b/app/build.gradle index 24e5f48..f2a71e5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,5 +1,6 @@ plugins { id 'com.android.application' + id 'kotlin-android' id 'com.mikepenz.aboutlibraries.plugin' } @@ -39,6 +40,9 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11 + } buildFeatures { viewBinding true } @@ -57,6 +61,7 @@ aboutLibraries{ dependencies { // androidX + implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.appcompat:appcompat:1.3.1' implementation "androidx.lifecycle:lifecycle-service:2.3.1" diff --git a/build.gradle b/build.gradle index 0c36113..5ed5ca3 100644 --- a/build.gradle +++ b/build.gradle @@ -9,6 +9,7 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:7.0.0' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.21' classpath 'com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:8.9.1' // NOTE: Do not place your application dependencies here; they belong From d65edf8949e19a1de0708294e4437b611950aa20 Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Tue, 3 Aug 2021 15:50:28 +0200 Subject: [PATCH 02/22] rewrite app in kotlin (1) --- app/build.gradle | 1 + .../github/shadow578/music_dl/KtPorted.java | 8 + .../shadow578/music_dl/LocaleOverride.java | 1 + .../github/shadow578/music_dl/MusicDLApp.java | 1 + .../music_dl/MusicDLGlideModule.java | 1 + .../shadow578/music_dl/backup/BackupData.java | 2 + .../music_dl/backup/BackupGSONAdapters.java | 5 + .../music_dl/backup/BackupHelper.java | 2 + .../music_dl/db/DBTypeConverters.java | 10 +- .../shadow578/music_dl/db/TracksDB.java | 2 + .../shadow578/music_dl/db/TracksDao.java | 2 + .../music_dl/db/model/TrackInfo.java | 2 + .../music_dl/db/model/TrackStatus.java | 3 + .../downloader/DownloaderException.java | 3 + .../downloader/DownloaderService.java | 2 + .../music_dl/downloader/TempFiles.java | 3 + .../downloader/TrackDownloadFormat.java | 2 + .../music_dl/downloader/TrackMetadata.java | 3 + .../downloader/wrapper/MP3agicWrapper.java | 2 + .../downloader/wrapper/YoutubeDLWrapper.java | 2 + .../share/DownloadBroadcastReceiver.java | 1 + .../music_dl/share/ShareTargetActivity.java | 2 + .../music_dl/ui/InsertTrackUIHelper.java | 2 + .../music_dl/ui/base/BaseActivity.java | 2 + .../music_dl/ui/base/BaseFragment.java | 3 + .../music_dl/ui/main/MainActivity.java | 2 + .../music_dl/ui/main/MainViewModel.java | 2 + .../music_dl/ui/more/MoreFragment.java | 2 + .../music_dl/ui/more/MoreViewModel.java | 2 + .../ui/splash/SplashScreenActivity.java | 2 + .../music_dl/ui/tracks/TracksAdapter.java | 2 + .../music_dl/ui/tracks/TracksFragment.java | 2 + .../music_dl/ui/tracks/TracksViewModel.java | 2 + .../github/shadow578/music_dl/util/Async.java | 3 + .../shadow578/music_dl/util/LocaleUtil.java | 2 + .../music_dl/util/SwipeToDeleteCallback.java | 3 + .../github/shadow578/music_dl/util/Util.java | 3 + .../notifications/NotificationChannels.java | 2 + .../util/preferences/PreferenceWrapper.java | 3 + .../music_dl/util/preferences/Prefs.java | 2 + .../music_dl/util/storage/StorageHelper.java | 3 + .../music_dl/util/storage/StorageKey.java | 3 + .../github/shadow578/yodel/LocaleOverride.kt | 49 ++ .../io/github/shadow578/yodel/YodelApp.kt | 26 + .../shadow578/yodel/YodelGlideModule.kt | 10 + .../shadow578/yodel/backup/BackupData.kt | 19 + .../yodel/backup/BackupGSONAdapters.kt | 66 ++ .../shadow578/yodel/backup/BackupHelper.kt | 108 +++ .../shadow578/yodel/db/DBTypeConverters.kt | 56 ++ .../io/github/shadow578/yodel/db/TracksDB.kt | 94 +++ .../io/github/shadow578/yodel/db/TracksDao.kt | 106 +++ .../shadow578/yodel/db/model/TrackInfo.kt | 95 +++ .../shadow578/yodel/db/model/TrackStatus.kt | 54 ++ .../yodel/downloader/DownloadService.kt | 643 ++++++++++++++++++ .../yodel/downloader/DownloaderException.kt | 12 + .../shadow578/yodel/downloader/TempFiles.kt | 85 +++ .../yodel/downloader/TrackDownloadFormat.kt | 50 ++ .../yodel/downloader/TrackMetadata.kt | 162 +++++ .../downloader/wrapper/MP3agicWrapper.kt | 88 +++ .../downloader/wrapper/YoutubeDLWrapper.kt | 266 ++++++++ .../shadow578/yodel/ui/InsertTrackUIHelper.kt | 87 +++ .../shadow578/yodel/ui/base/BaseActivity.kt | 94 +++ .../shadow578/yodel/ui/base/BaseFragment.kt | 8 + .../shadow578/yodel/ui/main/MainActivity.kt | 139 ++++ .../shadow578/yodel/ui/main/MainViewModel.kt | 49 ++ .../shadow578/yodel/ui/more/MoreFragment.kt | 222 ++++++ .../shadow578/yodel/ui/more/MoreViewModel.kt | 205 ++++++ .../yodel/ui/share/ShareTargetActivity.kt | 68 ++ .../yodel/ui/splash/SplashScreenActivity.kt | 23 + .../yodel/ui/tracks/TracksAdapter.kt | 209 ++++++ .../yodel/ui/tracks/TracksFragment.kt | 151 ++++ .../yodel/ui/tracks/TracksViewModel.kt | 15 + .../io/github/shadow578/yodel/util/Lang.kt | 28 + .../yodel/util/NotificationChannels.kt | 80 +++ .../yodel/util/SwipeToDeleteCallback.kt | 111 +++ .../io/github/shadow578/yodel/util/Util.kt | 121 ++++ .../util/preferences/PreferenceWrapper.kt | 91 +++ .../shadow578/yodel/util/preferences/Prefs.kt | 55 ++ .../yodel/util/storage/StorageHelper.kt | 137 ++++ .../yodel/util/storage/StorageKey.kt | 20 + 80 files changed, 4005 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/io/github/shadow578/music_dl/KtPorted.java create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/LocaleOverride.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/YodelApp.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/YodelGlideModule.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupData.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupGSONAdapters.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupHelper.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/db/DBTypeConverters.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/db/TracksDB.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/db/TracksDao.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/db/model/TrackInfo.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/db/model/TrackStatus.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloaderException.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/downloader/TempFiles.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackDownloadFormat.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackMetadata.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/MP3agicWrapper.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapper.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/ui/InsertTrackUIHelper.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/ui/base/BaseActivity.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/ui/base/BaseFragment.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/ui/main/MainActivity.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/ui/main/MainViewModel.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreFragment.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreViewModel.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/ui/share/ShareTargetActivity.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/ui/splash/SplashScreenActivity.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksAdapter.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksFragment.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksViewModel.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/util/Lang.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/util/NotificationChannels.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/util/SwipeToDeleteCallback.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/util/Util.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/util/preferences/PreferenceWrapper.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/util/preferences/Prefs.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/util/storage/StorageHelper.kt create mode 100644 app/src/main/kotlin/io/github/shadow578/yodel/util/storage/StorageKey.kt diff --git a/app/build.gradle b/app/build.gradle index f2a71e5..32d4a64 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -66,6 +66,7 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.3.1' implementation "androidx.lifecycle:lifecycle-service:2.3.1" implementation 'androidx.legacy:legacy-support-v4:1.0.0' + implementation 'androidx.preference:preference-ktx:1.1.1' // material design implementation 'com.google.android.material:material:1.4.0' diff --git a/app/src/main/java/io/github/shadow578/music_dl/KtPorted.java b/app/src/main/java/io/github/shadow578/music_dl/KtPorted.java new file mode 100644 index 0000000..0bc8efc --- /dev/null +++ b/app/src/main/java/io/github/shadow578/music_dl/KtPorted.java @@ -0,0 +1,8 @@ +package io.github.shadow578.music_dl; + +/** + * helper annotation to mark a class as ported to kotlin + */ +@Deprecated +public @interface KtPorted { +} diff --git a/app/src/main/java/io/github/shadow578/music_dl/LocaleOverride.java b/app/src/main/java/io/github/shadow578/music_dl/LocaleOverride.java index 1e16a99..da9345b 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/LocaleOverride.java +++ b/app/src/main/java/io/github/shadow578/music_dl/LocaleOverride.java @@ -11,6 +11,7 @@ /** * locale overrides */ +@KtPorted public enum LocaleOverride { SystemDefault, diff --git a/app/src/main/java/io/github/shadow578/music_dl/MusicDLApp.java b/app/src/main/java/io/github/shadow578/music_dl/MusicDLApp.java index 890726d..d79dc9e 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/MusicDLApp.java +++ b/app/src/main/java/io/github/shadow578/music_dl/MusicDLApp.java @@ -12,6 +12,7 @@ /** * application instance, for boilerplate init */ +@KtPorted public class MusicDLApp extends Application { @Override diff --git a/app/src/main/java/io/github/shadow578/music_dl/MusicDLGlideModule.java b/app/src/main/java/io/github/shadow578/music_dl/MusicDLGlideModule.java index 64891df..12d9b84 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/MusicDLGlideModule.java +++ b/app/src/main/java/io/github/shadow578/music_dl/MusicDLGlideModule.java @@ -7,5 +7,6 @@ * glide module. ikd what it does, but glide says we need it, so here it is */ @GlideModule +@KtPorted public class MusicDLGlideModule extends AppGlideModule { } diff --git a/app/src/main/java/io/github/shadow578/music_dl/backup/BackupData.java b/app/src/main/java/io/github/shadow578/music_dl/backup/BackupData.java index 58a6b81..66a5ed1 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/backup/BackupData.java +++ b/app/src/main/java/io/github/shadow578/music_dl/backup/BackupData.java @@ -6,11 +6,13 @@ import java.time.LocalDateTime; import java.util.List; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.db.model.TrackInfo; /** * backup data written by {@link BackupHelper} */ +@KtPorted public class BackupData { /** * the tracks in this backup diff --git a/app/src/main/java/io/github/shadow578/music_dl/backup/BackupGSONAdapters.java b/app/src/main/java/io/github/shadow578/music_dl/backup/BackupGSONAdapters.java index f5d15f2..bd0c879 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/backup/BackupGSONAdapters.java +++ b/app/src/main/java/io/github/shadow578/music_dl/backup/BackupGSONAdapters.java @@ -10,11 +10,15 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import io.github.shadow578.music_dl.KtPorted; + /** * gson adapters for backup data */ +@KtPorted public class BackupGSONAdapters { + @KtPorted public static class LocalDateTimeAdapter extends TypeAdapter { private static final DateTimeFormatter FORMAT = DateTimeFormatter.ISO_DATE_TIME; @@ -39,6 +43,7 @@ public LocalDateTime read(JsonReader in) throws IOException { } } + @KtPorted public static class LocalDateAdapter extends TypeAdapter { private static final DateTimeFormatter FORMAT = DateTimeFormatter.ISO_DATE; diff --git a/app/src/main/java/io/github/shadow578/music_dl/backup/BackupHelper.java b/app/src/main/java/io/github/shadow578/music_dl/backup/BackupHelper.java index 8f79192..9568d4b 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/backup/BackupHelper.java +++ b/app/src/main/java/io/github/shadow578/music_dl/backup/BackupHelper.java @@ -19,6 +19,7 @@ import java.util.List; import java.util.Optional; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.db.TracksDB; import io.github.shadow578.music_dl.db.model.TrackInfo; @@ -26,6 +27,7 @@ * tracks db backup helper class. * all functions must be called from a background thread */ +@KtPorted public class BackupHelper { /** * gson for backup serialization and deserialization diff --git a/app/src/main/java/io/github/shadow578/music_dl/db/DBTypeConverters.java b/app/src/main/java/io/github/shadow578/music_dl/db/DBTypeConverters.java index 8e9994f..7dad2f7 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/db/DBTypeConverters.java +++ b/app/src/main/java/io/github/shadow578/music_dl/db/DBTypeConverters.java @@ -4,17 +4,19 @@ import java.time.LocalDate; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.db.model.TrackStatus; import io.github.shadow578.music_dl.util.storage.StorageKey; /** * type converters for room */ +@KtPorted class DBTypeConverters { //region StorageKey @TypeConverter - public String fromStorageKey(StorageKey key) { + public static String fromStorageKey(StorageKey key) { if (key == null) { return null; } @@ -23,7 +25,7 @@ public String fromStorageKey(StorageKey key) { } @TypeConverter - public StorageKey toStorageKey(String key) { + public static StorageKey toStorageKey(String key) { if (key == null) { return null; } @@ -34,7 +36,7 @@ public StorageKey toStorageKey(String key) { //region TrackStatus @TypeConverter - public String fromTrackStatus(TrackStatus status) { + public static String fromTrackStatus(TrackStatus status) { if (status == null) { return null; } @@ -43,7 +45,7 @@ public String fromTrackStatus(TrackStatus status) { } @TypeConverter - public TrackStatus toTrackStatus(String key) { + public static TrackStatus toTrackStatus(String key) { if (key == null) { return null; } diff --git a/app/src/main/java/io/github/shadow578/music_dl/db/TracksDB.java b/app/src/main/java/io/github/shadow578/music_dl/db/TracksDB.java index e5a2c28..ab8e2af 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/db/TracksDB.java +++ b/app/src/main/java/io/github/shadow578/music_dl/db/TracksDB.java @@ -13,6 +13,7 @@ import java.util.List; import java.util.Optional; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.db.model.TrackInfo; import io.github.shadow578.music_dl.db.model.TrackStatus; import io.github.shadow578.music_dl.util.storage.StorageHelper; @@ -20,6 +21,7 @@ /** * the tracks database */ +@KtPorted @TypeConverters({ DBTypeConverters.class }) diff --git a/app/src/main/java/io/github/shadow578/music_dl/db/TracksDao.java b/app/src/main/java/io/github/shadow578/music_dl/db/TracksDao.java index da4fe55..5cbc47b 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/db/TracksDao.java +++ b/app/src/main/java/io/github/shadow578/music_dl/db/TracksDao.java @@ -10,12 +10,14 @@ import java.util.List; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.db.model.TrackInfo; /** * DAO for tracks */ @Dao +@KtPorted @SuppressWarnings("unused") public interface TracksDao { diff --git a/app/src/main/java/io/github/shadow578/music_dl/db/model/TrackInfo.java b/app/src/main/java/io/github/shadow578/music_dl/db/model/TrackInfo.java index 3d44ad9..0c1b4de 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/db/model/TrackInfo.java +++ b/app/src/main/java/io/github/shadow578/music_dl/db/model/TrackInfo.java @@ -10,6 +10,7 @@ import java.time.LocalDate; import java.util.Objects; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.util.storage.StorageKey; /** @@ -19,6 +20,7 @@ indices = { @Index("first_added_at") }) +@KtPorted public class TrackInfo { /** diff --git a/app/src/main/java/io/github/shadow578/music_dl/db/model/TrackStatus.java b/app/src/main/java/io/github/shadow578/music_dl/db/model/TrackStatus.java index e7990f9..8a1d43f 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/db/model/TrackStatus.java +++ b/app/src/main/java/io/github/shadow578/music_dl/db/model/TrackStatus.java @@ -3,9 +3,12 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import io.github.shadow578.music_dl.KtPorted; + /** * status of a track */ +@KtPorted public enum TrackStatus { /** * the track is not yet downloaded. diff --git a/app/src/main/java/io/github/shadow578/music_dl/downloader/DownloaderException.java b/app/src/main/java/io/github/shadow578/music_dl/downloader/DownloaderException.java index e9c7623..ff8f61e 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/downloader/DownloaderException.java +++ b/app/src/main/java/io/github/shadow578/music_dl/downloader/DownloaderException.java @@ -4,9 +4,12 @@ import java.io.IOException; +import io.github.shadow578.music_dl.KtPorted; + /** * exception used by {@link DownloaderService} */ +@KtPorted public class DownloaderException extends IOException { public DownloaderException(@NonNull String message) { diff --git a/app/src/main/java/io/github/shadow578/music_dl/downloader/DownloaderService.java b/app/src/main/java/io/github/shadow578/music_dl/downloader/DownloaderService.java index 569340e..b2ae941 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/downloader/DownloaderService.java +++ b/app/src/main/java/io/github/shadow578/music_dl/downloader/DownloaderService.java @@ -37,6 +37,7 @@ import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.R; import io.github.shadow578.music_dl.db.TracksDB; import io.github.shadow578.music_dl.db.model.TrackInfo; @@ -53,6 +54,7 @@ /** * tracks downloading service */ +@KtPorted public class DownloaderService extends LifecycleService { /** diff --git a/app/src/main/java/io/github/shadow578/music_dl/downloader/TempFiles.java b/app/src/main/java/io/github/shadow578/music_dl/downloader/TempFiles.java index 36f29a1..5c6aad1 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/downloader/TempFiles.java +++ b/app/src/main/java/io/github/shadow578/music_dl/downloader/TempFiles.java @@ -5,9 +5,12 @@ import java.io.File; import java.util.Optional; +import io.github.shadow578.music_dl.KtPorted; + /** * temporary files created by youtube-dl */ +@KtPorted public class TempFiles { /** diff --git a/app/src/main/java/io/github/shadow578/music_dl/downloader/TrackDownloadFormat.java b/app/src/main/java/io/github/shadow578/music_dl/downloader/TrackDownloadFormat.java index 00d6246..f9ebcea 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/downloader/TrackDownloadFormat.java +++ b/app/src/main/java/io/github/shadow578/music_dl/downloader/TrackDownloadFormat.java @@ -3,6 +3,7 @@ import androidx.annotation.NonNull; import androidx.annotation.StringRes; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.R; /** @@ -11,6 +12,7 @@ * TODO validate all formats actually work * TODO check if more formats support ID3 */ +@KtPorted public enum TrackDownloadFormat { /** diff --git a/app/src/main/java/io/github/shadow578/music_dl/downloader/TrackMetadata.java b/app/src/main/java/io/github/shadow578/music_dl/downloader/TrackMetadata.java index cdfd2e6..b8be259 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/downloader/TrackMetadata.java +++ b/app/src/main/java/io/github/shadow578/music_dl/downloader/TrackMetadata.java @@ -11,11 +11,14 @@ import java.util.List; import java.util.Optional; +import io.github.shadow578.music_dl.KtPorted; + /** * track metadata POJO. this is in the format that youtube-dl writes with the --write-info-json option * a lot of data is left out, as it's not really relevant for what we're doing (stuff like track info, thumbnails, ...) */ @SuppressWarnings("unused") +@KtPorted public class TrackMetadata { /** diff --git a/app/src/main/java/io/github/shadow578/music_dl/downloader/wrapper/MP3agicWrapper.java b/app/src/main/java/io/github/shadow578/music_dl/downloader/wrapper/MP3agicWrapper.java index 7445535..923f9e4 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/downloader/wrapper/MP3agicWrapper.java +++ b/app/src/main/java/io/github/shadow578/music_dl/downloader/wrapper/MP3agicWrapper.java @@ -16,11 +16,13 @@ import java.io.FileOutputStream; import java.io.IOException; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.util.Util; /** * wrapper for MP3agic to make working with it on android easier */ +@KtPorted public class MP3agicWrapper { /** diff --git a/app/src/main/java/io/github/shadow578/music_dl/downloader/wrapper/YoutubeDLWrapper.java b/app/src/main/java/io/github/shadow578/music_dl/downloader/wrapper/YoutubeDLWrapper.java index 6c9c455..dfa2beb 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/downloader/wrapper/YoutubeDLWrapper.java +++ b/app/src/main/java/io/github/shadow578/music_dl/downloader/wrapper/YoutubeDLWrapper.java @@ -16,12 +16,14 @@ import java.io.File; import io.github.shadow578.music_dl.BuildConfig; +import io.github.shadow578.music_dl.KtPorted; /** * wrapper for {@link com.yausername.youtubedl_android.YoutubeDL}. * all functions in this class should be run in a background thread only */ @SuppressWarnings({"unused", "UnusedReturnValue"}) +@KtPorted public class YoutubeDLWrapper { /** diff --git a/app/src/main/java/io/github/shadow578/music_dl/share/DownloadBroadcastReceiver.java b/app/src/main/java/io/github/shadow578/music_dl/share/DownloadBroadcastReceiver.java index 1e9c69f..8e7f444 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/share/DownloadBroadcastReceiver.java +++ b/app/src/main/java/io/github/shadow578/music_dl/share/DownloadBroadcastReceiver.java @@ -29,6 +29,7 @@ * this.sendBroadcast(broadcast); * */ +//TODO probably dropped in KT rewrite public class DownloadBroadcastReceiver extends BroadcastReceiver { /** diff --git a/app/src/main/java/io/github/shadow578/music_dl/share/ShareTargetActivity.java b/app/src/main/java/io/github/shadow578/music_dl/share/ShareTargetActivity.java index c6fcc7c..81023e4 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/share/ShareTargetActivity.java +++ b/app/src/main/java/io/github/shadow578/music_dl/share/ShareTargetActivity.java @@ -10,6 +10,7 @@ import java.util.Optional; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.R; import io.github.shadow578.music_dl.downloader.DownloaderService; import io.github.shadow578.music_dl.ui.InsertTrackUIHelper; @@ -18,6 +19,7 @@ /** * activity that handles shared youtube links (for download) */ +@KtPorted public class ShareTargetActivity extends AppCompatActivity { @Override diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/InsertTrackUIHelper.java b/app/src/main/java/io/github/shadow578/music_dl/ui/InsertTrackUIHelper.java index 354d405..443730e 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/InsertTrackUIHelper.java +++ b/app/src/main/java/io/github/shadow578/music_dl/ui/InsertTrackUIHelper.java @@ -6,6 +6,7 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.R; import io.github.shadow578.music_dl.db.TracksDB; import io.github.shadow578.music_dl.db.model.TrackInfo; @@ -14,6 +15,7 @@ /** * helper class for inserting tracks into the db from UI */ +@KtPorted public class InsertTrackUIHelper { /** diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/base/BaseActivity.java b/app/src/main/java/io/github/shadow578/music_dl/ui/base/BaseActivity.java index 19ccd59..e3f4627 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/base/BaseActivity.java +++ b/app/src/main/java/io/github/shadow578/music_dl/ui/base/BaseActivity.java @@ -15,6 +15,7 @@ import java.util.Optional; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.R; import io.github.shadow578.music_dl.downloader.DownloaderService; import io.github.shadow578.music_dl.util.LocaleUtil; @@ -26,6 +27,7 @@ * topmost base activity. this is to be extended when creating a new activity. * handles app- specific stuff */ +@KtPorted public class BaseActivity extends AppCompatActivity { /** diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/base/BaseFragment.java b/app/src/main/java/io/github/shadow578/music_dl/ui/base/BaseFragment.java index 7b0b48e..0864df0 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/base/BaseFragment.java +++ b/app/src/main/java/io/github/shadow578/music_dl/ui/base/BaseFragment.java @@ -2,9 +2,12 @@ import androidx.fragment.app.Fragment; +import io.github.shadow578.music_dl.KtPorted; + /** * base fragment, with some common functionality */ +@KtPorted public class BaseFragment extends Fragment { public BaseFragment() { diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/main/MainActivity.java b/app/src/main/java/io/github/shadow578/music_dl/ui/main/MainActivity.java index 3912bb5..44ed79c 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/main/MainActivity.java +++ b/app/src/main/java/io/github/shadow578/music_dl/ui/main/MainActivity.java @@ -14,6 +14,7 @@ import java.util.Arrays; import java.util.List; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.R; import io.github.shadow578.music_dl.databinding.ActivityMainBinding; import io.github.shadow578.music_dl.ui.base.BaseActivity; @@ -23,6 +24,7 @@ /** * the main activity */ +@KtPorted public class MainActivity extends BaseActivity { private final TracksFragment tracksFragment = new TracksFragment(); diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/main/MainViewModel.java b/app/src/main/java/io/github/shadow578/music_dl/ui/main/MainViewModel.java index 592f33b..f4721b8 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/main/MainViewModel.java +++ b/app/src/main/java/io/github/shadow578/music_dl/ui/main/MainViewModel.java @@ -8,11 +8,13 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.downloader.DownloaderService; /** * view model for the main activity */ +@KtPorted public class MainViewModel extends AndroidViewModel { public MainViewModel(@NonNull Application application) { super(application); diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/more/MoreFragment.java b/app/src/main/java/io/github/shadow578/music_dl/ui/more/MoreFragment.java index 5a06d47..7730f20 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/more/MoreFragment.java +++ b/app/src/main/java/io/github/shadow578/music_dl/ui/more/MoreFragment.java @@ -19,6 +19,7 @@ import java.util.List; import java.util.stream.Collectors; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.LocaleOverride; import io.github.shadow578.music_dl.R; import io.github.shadow578.music_dl.databinding.FragmentMoreBinding; @@ -28,6 +29,7 @@ /** * more / about fragment */ +@KtPorted public class MoreFragment extends BaseFragment { /** diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/more/MoreViewModel.java b/app/src/main/java/io/github/shadow578/music_dl/ui/more/MoreViewModel.java index e98d3dc..835c3df 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/more/MoreViewModel.java +++ b/app/src/main/java/io/github/shadow578/music_dl/ui/more/MoreViewModel.java @@ -16,6 +16,7 @@ import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.LocaleOverride; import io.github.shadow578.music_dl.R; import io.github.shadow578.music_dl.backup.BackupData; @@ -28,6 +29,7 @@ /** * view model for more fragment */ +@KtPorted public class MoreViewModel extends AndroidViewModel { public MoreViewModel(@NonNull Application application) { super(application); diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/splash/SplashScreenActivity.java b/app/src/main/java/io/github/shadow578/music_dl/ui/splash/SplashScreenActivity.java index aed9457..3b742a8 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/splash/SplashScreenActivity.java +++ b/app/src/main/java/io/github/shadow578/music_dl/ui/splash/SplashScreenActivity.java @@ -6,6 +6,7 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.ui.main.MainActivity; import io.github.shadow578.music_dl.util.Async; @@ -13,6 +14,7 @@ * basic splash- screen activity. * displays a splash- screen, then redirects the user to the correct activity */ +@KtPorted public class SplashScreenActivity extends AppCompatActivity { @Override diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksAdapter.java b/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksAdapter.java index e977095..d562493 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksAdapter.java +++ b/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksAdapter.java @@ -18,6 +18,7 @@ import java.util.List; import java.util.Optional; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.R; import io.github.shadow578.music_dl.databinding.RecyclerTrackViewBinding; import io.github.shadow578.music_dl.db.model.TrackInfo; @@ -29,6 +30,7 @@ /** * recyclerview adapter for tracks livedata */ +@KtPorted public class TracksAdapter extends RecyclerView.Adapter { @NonNull diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksFragment.java b/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksFragment.java index ca1ada5..7470716 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksFragment.java +++ b/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksFragment.java @@ -20,6 +20,7 @@ import java.util.Optional; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.R; import io.github.shadow578.music_dl.databinding.FragmentTracksBinding; import io.github.shadow578.music_dl.db.TracksDB; @@ -33,6 +34,7 @@ /** * downloaded and downloading tracks UI */ +@KtPorted public class TracksFragment extends BaseFragment { /** diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksViewModel.java b/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksViewModel.java index 9c785da..f8c82eb 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksViewModel.java +++ b/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksViewModel.java @@ -8,9 +8,11 @@ import java.util.List; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.db.TracksDB; import io.github.shadow578.music_dl.db.model.TrackInfo; +@KtPorted public class TracksViewModel extends AndroidViewModel { public TracksViewModel(@NonNull Application application) { super(application); diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/Async.java b/app/src/main/java/io/github/shadow578/music_dl/util/Async.java index b912611..6ffc798 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/util/Async.java +++ b/app/src/main/java/io/github/shadow578/music_dl/util/Async.java @@ -8,10 +8,13 @@ import java.util.concurrent.Executor; import java.util.concurrent.Executors; +import io.github.shadow578.music_dl.KtPorted; + /** * async operation utilities */ @SuppressWarnings("unused") +@KtPorted public class Async { /** * handler to run functions on the main thread. diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/LocaleUtil.java b/app/src/main/java/io/github/shadow578/music_dl/util/LocaleUtil.java index e192b99..dd032c2 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/util/LocaleUtil.java +++ b/app/src/main/java/io/github/shadow578/music_dl/util/LocaleUtil.java @@ -8,12 +8,14 @@ import androidx.annotation.NonNull; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.LocaleOverride; import io.github.shadow578.music_dl.util.preferences.Prefs; /** * locale utility class */ +@KtPorted public class LocaleUtil { /** * wrap the config to use the target locale from {@link Prefs#LocaleOverride} diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/SwipeToDeleteCallback.java b/app/src/main/java/io/github/shadow578/music_dl/util/SwipeToDeleteCallback.java index ad65375..d104f6c 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/util/SwipeToDeleteCallback.java +++ b/app/src/main/java/io/github/shadow578/music_dl/util/SwipeToDeleteCallback.java @@ -15,9 +15,12 @@ import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; +import io.github.shadow578.music_dl.KtPorted; + /** * swipe to delete handler for {@link ItemTouchHelper} */ +@KtPorted public abstract class SwipeToDeleteCallback extends ItemTouchHelper.SimpleCallback { /** diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/Util.java b/app/src/main/java/io/github/shadow578/music_dl/util/Util.java index 02bbce6..7bdbf8d 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/util/Util.java +++ b/app/src/main/java/io/github/shadow578/music_dl/util/Util.java @@ -12,9 +12,12 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import io.github.shadow578.music_dl.KtPorted; + /** * general utility */ +@KtPorted public final class Util { //region youtube util diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/notifications/NotificationChannels.java b/app/src/main/java/io/github/shadow578/music_dl/util/notifications/NotificationChannels.java index e0be86d..74188e4 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/util/notifications/NotificationChannels.java +++ b/app/src/main/java/io/github/shadow578/music_dl/util/notifications/NotificationChannels.java @@ -8,12 +8,14 @@ import androidx.core.app.NotificationChannelCompat; import androidx.core.app.NotificationManagerCompat; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.R; /** * class to handle notification channels */ @SuppressWarnings("unused") +@KtPorted public enum NotificationChannels { /** * default notification channel. diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/preferences/PreferenceWrapper.java b/app/src/main/java/io/github/shadow578/music_dl/util/preferences/PreferenceWrapper.java index 201079d..c5b493b 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/util/preferences/PreferenceWrapper.java +++ b/app/src/main/java/io/github/shadow578/music_dl/util/preferences/PreferenceWrapper.java @@ -7,11 +7,14 @@ import com.google.gson.Gson; +import io.github.shadow578.music_dl.KtPorted; + /** * wrapper class for shared preferences. init before first use using {@link #init(SharedPreferences)} * * @param the type of this preference */ +@KtPorted public class PreferenceWrapper { /** diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/preferences/Prefs.java b/app/src/main/java/io/github/shadow578/music_dl/util/preferences/Prefs.java index c382dce..ff27629 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/util/preferences/Prefs.java +++ b/app/src/main/java/io/github/shadow578/music_dl/util/preferences/Prefs.java @@ -1,5 +1,6 @@ package io.github.shadow578.music_dl.util.preferences; +import io.github.shadow578.music_dl.KtPorted; import io.github.shadow578.music_dl.LocaleOverride; import io.github.shadow578.music_dl.downloader.TrackDownloadFormat; import io.github.shadow578.music_dl.downloader.wrapper.YoutubeDLWrapper; @@ -8,6 +9,7 @@ /** * app preferences storage */ +@KtPorted public final class Prefs { /** diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/storage/StorageHelper.java b/app/src/main/java/io/github/shadow578/music_dl/util/storage/StorageHelper.java index a16806c..4e96ce7 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/util/storage/StorageHelper.java +++ b/app/src/main/java/io/github/shadow578/music_dl/util/storage/StorageHelper.java @@ -12,10 +12,13 @@ import java.nio.charset.StandardCharsets; import java.util.Optional; +import io.github.shadow578.music_dl.KtPorted; + /** * helper class for storage framework */ @SuppressWarnings("unused") +@KtPorted public class StorageHelper { // region URI encode / decode diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/storage/StorageKey.java b/app/src/main/java/io/github/shadow578/music_dl/util/storage/StorageKey.java index 759234e..4f2c001 100644 --- a/app/src/main/java/io/github/shadow578/music_dl/util/storage/StorageKey.java +++ b/app/src/main/java/io/github/shadow578/music_dl/util/storage/StorageKey.java @@ -2,9 +2,12 @@ import androidx.annotation.NonNull; +import io.github.shadow578.music_dl.KtPorted; + /** * a storage key, used by {@link StorageHelper} */ +@KtPorted public class StorageKey { /** diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/LocaleOverride.kt b/app/src/main/kotlin/io/github/shadow578/yodel/LocaleOverride.kt new file mode 100644 index 0000000..a54761c --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/LocaleOverride.kt @@ -0,0 +1,49 @@ +package io.github.shadow578.yodel + +import android.content.Context +import io.github.shadow578.music_dl.R +import java.util.* + +/** + * locale overrides + * + * @param locale local to use for the override + */ +enum class LocaleOverride(val locale: Locale? = null) { + + /** + * follow system default locale + */ + SystemDefault, + + /** + * english locale + */ + English(Locale("en", "")), + + /** + * german (germany) locale + */ + German(Locale("de", "DE")); + + /** + * get the display name for this locale override + * + * @param ctx the context to work in + * @return the display name of the locale override + */ + fun getDisplayName(ctx: Context): String { + // check if this is FollowSystem + if (this == SystemDefault) + return ctx.getString(R.string.locale_system_default) + + // for any other case throw a exception if locale == null + // to make kotlin shut up + if (locale == null) + throw NullPointerException("locale null, but not SystemDefault!") + + // not SystemDefault, get locale name + return locale.getDisplayName(locale) + + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/YodelApp.kt b/app/src/main/kotlin/io/github/shadow578/yodel/YodelApp.kt new file mode 100644 index 0000000..bf37309 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/YodelApp.kt @@ -0,0 +1,26 @@ +package io.github.shadow578.yodel + +import android.app.Application +import android.util.Log +import androidx.preference.PreferenceManager +import io.github.shadow578.music_dl.db.TracksDB +import io.github.shadow578.yodel.util.NotificationChannels +import io.github.shadow578.yodel.util.launchIO +import io.github.shadow578.yodel.util.preferences.PreferenceWrapper + +/** + * application class, for boilerplate init + */ +class YodelApp : Application() { + override fun onCreate() { + super.onCreate() + PreferenceWrapper.init(PreferenceManager.getDefaultSharedPreferences(this)) + NotificationChannels.registerAll(this) + + // find tracks that were deleted + launchIO { + val removedCount = TracksDB.init(this@YodelApp).markDeletedTracks(this@YodelApp) + Log.i("Yodel", "found $removedCount tracks that were deleted in the file system") + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/YodelGlideModule.kt b/app/src/main/kotlin/io/github/shadow578/yodel/YodelGlideModule.kt new file mode 100644 index 0000000..4271567 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/YodelGlideModule.kt @@ -0,0 +1,10 @@ +package io.github.shadow578.yodel + +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.module.AppGlideModule + +/** + * glide module. ikd what it does, but glide says we need it, so here it is + */ +@GlideModule +class YodelGlideModule : AppGlideModule() \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupData.kt b/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupData.kt new file mode 100644 index 0000000..f3941b9 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupData.kt @@ -0,0 +1,19 @@ +package io.github.shadow578.yodel.backup + +import io.github.shadow578.music_dl.backup.BackupHelper +import io.github.shadow578.music_dl.db.model.TrackInfo +import java.time.LocalDateTime + +/** + * backup data written by [BackupHelper] + */ +class BackupData( + /** + * the tracks in this backup + */ + val tracks: List, + /** + * the time the backup was created + */ + val backupTime: LocalDateTime +) \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupGSONAdapters.kt b/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupGSONAdapters.kt new file mode 100644 index 0000000..1b30ae6 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupGSONAdapters.kt @@ -0,0 +1,66 @@ +package io.github.shadow578.yodel.backup + +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter +import java.io.IOException +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +/** + * gson adapter for [LocalDateTime] + */ +class LocalDateTimeAdapter : TypeAdapter() { + @Throws(IOException::class) + override fun write(writer: JsonWriter, value: LocalDateTime?) { + if (value == null) { + writer.nullValue() + } else { + writer.value(FORMAT.format(value)) + } + } + + @Throws(IOException::class) + override fun read(reader: JsonReader): LocalDateTime? { + return if (reader.peek() == JsonToken.NULL) { + reader.nextNull() + null + } else { + LocalDateTime.parse(reader.nextString(), FORMAT) + } + } + + companion object { + private val FORMAT = DateTimeFormatter.ISO_DATE_TIME + } +} + +/** + * gson adapter for [LocalDate] + */ +class LocalDateAdapter : TypeAdapter() { + @Throws(IOException::class) + override fun write(writer: JsonWriter, value: LocalDate?) { + if (value == null) { + writer.nullValue() + } else { + writer.value(FORMAT.format(value)) + } + } + + @Throws(IOException::class) + override fun read(reader: JsonReader): LocalDate? { + return if (reader.peek() == JsonToken.NULL) { + reader.nextNull() + null + } else { + LocalDate.parse(reader.nextString(), FORMAT) + } + } + + companion object { + private val FORMAT = DateTimeFormatter.ISO_DATE + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupHelper.kt b/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupHelper.kt new file mode 100644 index 0000000..19cc80d --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupHelper.kt @@ -0,0 +1,108 @@ +package io.github.shadow578.yodel.backup + +import android.content.Context +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import com.google.gson.GsonBuilder +import com.google.gson.JsonIOException +import com.google.gson.JsonSyntaxException +import io.github.shadow578.music_dl.db.TracksDB +import java.io.IOException +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.* + +/** + * tracks db backup helper class. + * all functions must be called from a background thread + */ +object BackupHelper { + /** + * gson for backup serialization and deserialization + */ + private val gson = GsonBuilder() + .registerTypeAdapter(LocalDateTime::class.java, LocalDateTimeAdapter()) + .registerTypeAdapter(LocalDate::class.java, LocalDateAdapter()) + .create() + + /** + * tag for logging + */ + private const val TAG = "BackupHelper" + + /** + * create a new backup of all tracks + * + * @param ctx the context to work in + * @param file the file to write the backup to + * @return was the backup successful + */ + fun createBackup(ctx: Context, file: DocumentFile): Boolean { + // get all tracks in DB + val tracks = TracksDB.init(ctx).tracks().all + if (tracks.size <= 0) return false + + // create backup data + val backup = BackupData(tracks, LocalDateTime.now()) + + // serialize and write to file + try { + OutputStreamWriter(ctx.contentResolver.openOutputStream(file.uri)).use { out -> + gson.toJson(backup, out) + return true + } + } catch (e: IOException) { + Log.e(TAG, "writing backup file failed!", e) + return false + } + } + + /** + * read backup data from a file + * + * @param ctx the context to read in + * @param file the file to read the data from + * @return the backup data + */ + fun readBackupData(ctx: Context, file: DocumentFile): Optional { + try { + InputStreamReader(ctx.contentResolver.openInputStream(file.uri)).use { src -> + return Optional.ofNullable( + gson.fromJson( + src, + BackupData::class.java + ) + ) + } + } catch (e: IOException) { + Log.e(TAG, "failed to read backup data!", e) + return Optional.empty() + } catch (e: JsonSyntaxException) { + Log.e(TAG, "failed to read backup data!", e) + return Optional.empty() + } catch (e: JsonIOException) { + Log.e(TAG, "failed to read backup data!", e) + return Optional.empty() + } + } + + /** + * restore a backup into the db + * + * @param ctx the context to work in + * @param data the data to restore + * @param replaceExisting if true, existing entries are overwritten. if false, existing entries are not added + */ + fun restoreBackup(ctx: Context, data: BackupData, replaceExisting: Boolean) { + // check there are tracks to import + if (data.tracks.size <= 0) return + + // insert the tracks + if (replaceExisting) + TracksDB.init(ctx).tracks().insertAll(data.tracks) + else + TracksDB.init(ctx).tracks().insertAllNew(data.tracks) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/db/DBTypeConverters.kt b/app/src/main/kotlin/io/github/shadow578/yodel/db/DBTypeConverters.kt new file mode 100644 index 0000000..5152329 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/db/DBTypeConverters.kt @@ -0,0 +1,56 @@ +package io.github.shadow578.yodel.db + +import androidx.room.TypeConverter +import io.github.shadow578.yodel.db.model.TrackStatus +import io.github.shadow578.yodel.util.storage.StorageKey +import java.time.LocalDate + +/** + * type converters for room + */ +class DBTypeConverters { + //region StorageKey + @TypeConverter + fun fromStorageKey(key: StorageKey?): String? { + return key?.toString() + } + + @TypeConverter + fun toStorageKey(key: String?): StorageKey? { + return if (key == null) { + null + } else StorageKey(key) + } + + //endregion + + //region TrackStatus + @TypeConverter + fun fromTrackStatus(status: TrackStatus?): String? { + return status?.key + } + + @TypeConverter + fun toTrackStatus(key: String?): TrackStatus? { + if (key == null) { + return null + } + val s = TrackStatus.findByKey(key) + return s ?: TrackStatus.DownloadPending + } + + //endregion + + //region LocalDate + @TypeConverter + fun fromLocalDate(date: LocalDate?): String? { + return date?.toString() + } + + @TypeConverter + fun toLocalDate(string: String?): LocalDate? { + return if (string == null) { + null + } else LocalDate.parse(string) + } //endregion +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/db/TracksDB.kt b/app/src/main/kotlin/io/github/shadow578/yodel/db/TracksDB.kt new file mode 100644 index 0000000..faf1bcf --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/db/TracksDB.kt @@ -0,0 +1,94 @@ +package io.github.shadow578.yodel.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import io.github.shadow578.yodel.db.model.TrackInfo +import io.github.shadow578.yodel.db.model.TrackStatus +import io.github.shadow578.yodel.util.storage.decodeToFile + +/** + * the tracks database + */ +@TypeConverters(DBTypeConverters::class) +@Database( + entities = [ + TrackInfo::class + ], + version = 7 +) +abstract class TracksDB : RoomDatabase() { + /** + * mark all tracks db that no longer exist (or are not accessible) in the file system as [TrackStatus.FileDeleted] + * + * @param ctx the context to get the files in + * @return the number of removed tracks + */ + fun markDeletedTracks(ctx: Context): Int { + // get all tracks that are (supposedly) downloaded + val supposedlyDownloadedTracks = tracks().downloaded + + // check on every track if the downloaded file still exists + var count = 0 + for (track in supposedlyDownloadedTracks) { + // get file for this track + val trackFile = track.audioFileKey.decodeToFile(ctx) + + // if the file could not be decoded, + // the file cannot be read OR it does not exist + // assume it was removed + if (trackFile != null + && trackFile.canRead() + && trackFile.exists() + ) { + // file still there, do no more + continue + } + + // mark as deleted track + track.status = TrackStatus.FileDeleted + tracks().update(track) + count++ + } + return count + } + + /** + * @return the tracks DAO + */ + abstract fun tracks(): TracksDao + + companion object { + /** + * database name + */ + const val DB_NAME = "tracks" + + /** + * the instance singleton + */ + private lateinit var instance: TracksDB + + /** + * initialize the database + * + * @param ctx the context to work in + * @return the freshly initialized instance + */ + fun get(ctx: Context): TracksDB { + if (!this::instance.isInitialized) { + // have to initialize db + instance = Room.databaseBuilder( + ctx, + TracksDB::class.java, DB_NAME + ) + .fallbackToDestructiveMigration() + .build() + } + + return instance + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/db/TracksDao.kt b/app/src/main/kotlin/io/github/shadow578/yodel/db/TracksDao.kt new file mode 100644 index 0000000..514af54 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/db/TracksDao.kt @@ -0,0 +1,106 @@ +package io.github.shadow578.yodel.db + +import androidx.lifecycle.LiveData +import androidx.room.* +import io.github.shadow578.yodel.db.model.TrackInfo + +/** + * DAO for tracks + */ +@Dao +interface TracksDao { + /** + * observe all tracks + * + * @return the tracks that can be observed + */ + @Query("SELECT * FROM tracks ORDER BY first_added_at ASC") + fun observe(): LiveData> + + /** + * observe all tracks that have to be downloaded + * + * @return the tracks that can be observed + */ + @Query("SELECT * FROM tracks WHERE status = 'pending'") + fun observePending(): LiveData> + + /** + * get a list of all tracks + * + * @return a list of all tracks + */ + @get:Query("SELECT * FROM tracks") + val all: List + + /** + * get a list of all tracks that are marked as downloaded + * + * @return a list of all downloaded tracks + */ + @get:Query("SELECT * FROM tracks WHERE status = 'downloaded'") + val downloaded: List + + /** + * reset all tracks in downloading status back to pending + */ + @Query("UPDATE tracks SET status = 'pending' WHERE status = 'downloading'") + fun resetDownloadingToPending() + + /** + * get a track from the db + * + * @param id the id of the track + * @return the track, or null if not found + */ + @Query("SELECT * FROM tracks WHERE id = :id") + operator fun get(id: String?): TrackInfo? + + /** + * insert a track into the db + * + * @param track the track to insert + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(track: TrackInfo) + + /** + * insert multiple tracks into the db, replacing existing entries + * + * @param tracks the tracks to insert + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(tracks: List) + + /** + * insert multiple tracks into the db, skipping existing entries + * + * @param tracks the tracks to insert + */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insertAllNew(tracks: List) + + /** + * update a track + * + * @param track the track to update + */ + @Update + fun update(track: TrackInfo) + + /** + * remove a single track from the db + * + * @param track the track to remove + */ + @Delete + fun remove(track: TrackInfo) + + /** + * remove multiple tracks from the db + * + * @param tracks the tracks to remove + */ + @Delete + fun removeAll(tracks: List) +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/db/model/TrackInfo.kt b/app/src/main/kotlin/io/github/shadow578/yodel/db/model/TrackInfo.kt new file mode 100644 index 0000000..fe1d9ab --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/db/model/TrackInfo.kt @@ -0,0 +1,95 @@ +package io.github.shadow578.yodel.db.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import io.github.shadow578.yodel.util.storage.StorageKey +import java.time.LocalDate + +/** + * information about a track + */ +@Entity( + tableName = "tracks", + indices = [ + Index("first_added_at") + ] +) +data class TrackInfo( + /** + * the youtube id of the track + */ + @field:ColumnInfo(name = "id") + @field:PrimaryKey + val id: String, + + /** + * the title of the track + */ + @field:ColumnInfo(name = "track_title") + var title: String, + + /** + * the name of the artist + */ + @field:ColumnInfo(name = "artist_name") + var artist: String? = null, + + /** + * the day the track was released / uploaded + */ + @field:ColumnInfo(name = "release_date") + var releaseDate: LocalDate? = null, + + /** + * duration of the track, in seconds + */ + @field:ColumnInfo(name = "duration") + var duration: Long? = null, + + /** + * the album name, if this track is part of one + */ + @field:ColumnInfo(name = "album_name") + var albumName: String? = null, + + /** + * the key of the file this track was downloaded to + */ + @field:ColumnInfo(name = "audio_file_key") + var audioFileKey: StorageKey = StorageKey.EMPTY, + + /** + * the key of the track cover image file + */ + @field:ColumnInfo(name = "cover_file_key") + var coverKey: StorageKey = StorageKey.EMPTY, + + /** + * is this track fully downloaded? + */ + @field:ColumnInfo(name = "status") + var status: TrackStatus = TrackStatus.DownloadPending, + + /** + * when this track was first added. millis timestamp, from [System.currentTimeMillis] + */ + @field:ColumnInfo(name = "first_added_at") + val firstAddedAt: Long = System.currentTimeMillis() +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as TrackInfo + + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int { + return id.hashCode() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/db/model/TrackStatus.kt b/app/src/main/kotlin/io/github/shadow578/yodel/db/model/TrackStatus.kt new file mode 100644 index 0000000..8021816 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/db/model/TrackStatus.kt @@ -0,0 +1,54 @@ +package io.github.shadow578.yodel.db.model + +/** + * status of a track + * + * @param key the key to set + */ +enum class TrackStatus( + val key: String +) { + /** + * the track is not yet downloaded. + * The next pass of the download service will download this track + */ + DownloadPending("pending"), + + /** + * the track is currently being downloaded + */ + Downloading("downloading"), + + /** + * the track was downloaded. it will not re- download again + */ + Downloaded("downloaded"), + + /** + * the download of the track failed. it will not re- download again + */ + DownloadFailed("failed"), + + /** + * the track was deleted on the file system. it will not re- download again + * the database record remains, but with the fileKey cleared (as its invalid) + */ + FileDeleted("deleted"); + + companion object { + /** + * find a status by its key + * + * @param key the key to find + * @return the status. if not found, returns null + */ + fun findByKey(key: String): TrackStatus? { + for (status in values()) + if (status.key == key) + return status + + return null + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt new file mode 100644 index 0000000..45e398b --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt @@ -0,0 +1,643 @@ +package io.github.shadow578.yodel.downloader + +import android.app.Notification +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.LifecycleService +import com.google.gson.Gson +import com.google.gson.JsonIOException +import com.google.gson.JsonSyntaxException +import com.mpatric.mp3agic.InvalidDataException +import com.mpatric.mp3agic.NotSupportedException +import com.mpatric.mp3agic.UnsupportedTagException +import io.github.shadow578.music_dl.R +import io.github.shadow578.yodel.db.TracksDB +import io.github.shadow578.yodel.db.model.TrackInfo +import io.github.shadow578.yodel.db.model.TrackStatus +import io.github.shadow578.yodel.downloader.wrapper.MP3agicWrapper +import io.github.shadow578.yodel.downloader.wrapper.YoutubeDLWrapper +import io.github.shadow578.yodel.util.* +import io.github.shadow578.yodel.util.preferences.Prefs +import io.github.shadow578.yodel.util.storage.StorageKey +import io.github.shadow578.yodel.util.storage.encodeToKey +import io.github.shadow578.yodel.util.storage.getPersistedFilePermission +import java.io.* +import java.util.* +import java.util.concurrent.BlockingQueue +import java.util.concurrent.LinkedBlockingQueue +import kotlin.math.floor + +/** + * tracks downloading service + */ +class DownloaderService : LifecycleService() { + companion object { + /** + * tag for logging + */ + private const val TAG = "DLService" + + /** + * retries for youtube-dl operations + */ + private const val YOUTUBE_DL_RETRIES = 10 + + /** + * notification id of the progress notification + */ + private const val PROGRESS_NOTIFICATION_ID = 123456 + } + + /** + * a list of all tracks that are scheduled to be downloaded. + * tracks are only removed from the set after they have been downloaded, and updated in the database + * this list is processed sequentially by [downloadThread] + */ + private val scheduledDownloads: BlockingQueue = LinkedBlockingQueue() + + /** + * the main download thread. runs in [downloadThread] + */ + private val downloadThread = Thread { downloadThread() } + + /** + * gson instance + */ + private val gson = Gson() + + /** + * notification manager, for progress notification + */ + private lateinit var notificationManager: NotificationManagerCompat + + /** + * is the service currently in foreground? + */ + private var isInForeground = false + + override fun onCreate() { + super.onCreate() + + // ensure downloads are accessible + if (!checkDownloadsDirSet()) { + Toast.makeText( + this, + "Downloads directory not accessible, stopping Downloader!", + Toast.LENGTH_LONG + ).show() + Log.i(TAG, "downloads dir not accessible, stopping service") + stopSelf() + return + } + + // create progress notification + notificationManager = NotificationManagerCompat.from(this) + + // init db and observe changes to pending tracks + Log.i(TAG, "start observing pending tracks...") + TracksDB.get(this).tracks().observePending().observe(this, + { pendingTracks: List -> + Log.i(TAG, String.format("pendingTracks update! size= ${pendingTracks.size}")) + + // enqueue all that are not scheduled already + for (track in pendingTracks) { + // ignore if track not pending + if (scheduledDownloads.contains(track) || track.status != TrackStatus.DownloadPending) continue + + //enqueue the track + scheduledDownloads.put(track) + } + }) + + // start downloader thread as daemon + downloadThread.name = "io.github.shadow578.yodel.downloader.DOWNLOAD_THREAD" + downloadThread.isDaemon = true + downloadThread.start() + } + + override fun onDestroy() { + Log.i(TAG, "destroying service...") + downloadThread.interrupt() + hideNotification() + super.onDestroy() + } + + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(newBase.wrapLocale()) + } + + /** + * check if the downloads directory is set and accessible + * + * @return is the downloads dir set and accessible? + */ + private fun checkDownloadsDirSet(): Boolean { + val downloadsKey = Prefs.DownloadsDirectory.get() + if (downloadsKey != StorageKey.EMPTY) { + val downloadsDir = downloadsKey.getPersistedFilePermission(this, true) + return (downloadsDir != null + && downloadsDir.exists() + && downloadsDir.canWrite()) + } + return false + } + + //region downloader top- level + /** + * the main download thread + */ + private fun downloadThread() { + try { + // reset in- progress downloads back to pending + TracksDB.get(this).tracks().resetDownloadingToPending() + + // init youtube-dl + Log.i(TAG, "downloader thread starting...") + if (!YoutubeDLWrapper.init(this)) { + Log.e(TAG, "youtube-dl init failed, stopping service") + stopSelf() + return + } + + // main loop + while (!Thread.interrupted()) { + while (true) { + // download + downloadTrack(scheduledDownloads.take()) + + // remove notification + hideNotification() + } + } + } catch (ignored: InterruptedException) { + } + } + + /** + * download a track + * + * @param track the track to download + */ + private fun downloadTrack(track: TrackInfo) { + // double- check the track is not downloaded + val dbTrack = TracksDB.get(this).tracks()[track.id] + if (dbTrack == null || dbTrack.status != TrackStatus.DownloadPending) { + Log.i(TAG, "skipping download of ${track.id}: appears to already be downloaded") + return + } + + // set status to downloading + track.status = TrackStatus.Downloading + TracksDB.get(this).tracks().update(track) + + // download the track + val downloadOk = download(track, Prefs.DownloadFormat.get()) + + // update the entry in the DB + track.status = if (downloadOk) TrackStatus.Downloaded else TrackStatus.DownloadFailed + TracksDB.get(this).tracks().update(track) + } + + /** + * download the track and resolve metadata + * + * @param track the track to download + * @param format the file format to download the track in + * @return was the download successful? + */ + private fun download(track: TrackInfo, format: TrackDownloadFormat): Boolean { + var files: TempFiles? = null + return try { + // create session + updateNotification( + createStatusNotification( + track, + R.string.dl_status_starting_download + ) + ) + val session = createSession(track, format) + files = createTempFiles(track, format) + + // download the track and metadata using youtube-dl + downloadTrack(track, session, files) + + // parse the metadata + updateNotification(createStatusNotification(track, R.string.dl_status_process_metadata)) + parseMetadata(track, files) + + // write id3v2 metadata for mp3 files + // if this fails, we do not fail the whole operation + if (format.supportsID3Tags && Prefs.EnableMetadataTagging.get()) + try { + writeID3Tag(track, files) + } catch (e: DownloaderException) { + Log.e( + TAG, + "failed to write id3v2 tags of ${track.id}! (not fatal, the rest of the download was successful)", + e + ) + } + + // copy audio file to downloads dir + updateNotification(createStatusNotification(track, R.string.dl_status_finish)) + copyAudioToFinal(track, files, format) + + // copy cover to cover store + // if this fails, we do not fail the whole operation + try { + copyCoverToFinal(track, files) + } catch (e: DownloaderException) { + Log.e( + TAG, + "failed to copy cover of ${track.id}! (not fatal, the rest of the download was successful)", + e + ) + } + true + } catch (e: DownloaderException) { + Log.e(TAG, "download of ${track.id} failed!", e) + false + } finally { + // delete temp files + if (files != null && !files.delete()) + Log.w(TAG, "could not delete temp files for ${track.id}") + } + } + //endregion + + //region downloader implementation + /** + * prepare a new youtube-dl session for the track + * + * @param track the track to prepare the session for + * @param format the file format to download the track in + * @return the youtube-dl session + * @throws DownloaderException if the cache directory could not be created (needed for the session) + */ + @Throws(DownloaderException::class) + private fun createSession(track: TrackInfo, format: TrackDownloadFormat): YoutubeDLWrapper { + val session = YoutubeDLWrapper(resolveVideoUrl(track)) + .cacheDir(downloadCacheDirectory) + .audioOnly(format.fileExtension) + + // enable ssl fix + if (Prefs.EnableSSLFix.get()) + session.fixSsl() + return session + } + + /** + * create the temporary files for the download + * + * @param track the track to create the files for + * @param format the file format to download in + * @return the temporary files + */ + private fun createTempFiles(track: TrackInfo, format: TrackDownloadFormat): TempFiles { + val tempAudio = cacheDir.getTempFile("dl_" + track.id, "") + return TempFiles(tempAudio, format.fileExtension) + } + + /** + * invoke youtube-dl to download the track + metadata + thumbnail + * + * @param track the track to download + * @param session the current youtube-dl session + * @param files the files to write + * @throws DownloaderException if download fails + */ + @Throws(DownloaderException::class) + private fun downloadTrack(track: TrackInfo, session: YoutubeDLWrapper, files: TempFiles) { + // make sure all files to create are non- existent + files.delete() + + // download + val downloadResponse = session.output(files.audio) + //.overwriteExisting() + .writeMetadata() + .writeThumbnail() + .download({ progress: Float, etaInSeconds: Long -> + updateNotification( + createProgressNotification(track, progress / 100.0, etaInSeconds) + ) + }, YOUTUBE_DL_RETRIES) + if (downloadResponse == null || !files.audio.exists() || !files.metadataJson.exists()) + throw DownloaderException("youtube-dl download failed!") + } + + /** + * parse the metadata file and update the values in the track + * + * @param track the track to update + * @param files the files created by youtube-dl + * @throws DownloaderException if parsing fails + */ + @Throws(DownloaderException::class) + private fun parseMetadata(track: TrackInfo, files: TempFiles) { + // check metadata file exists + if (!files.metadataJson.exists()) + throw DownloaderException("metadata file not found!") + + // deserialize the file + val metadata: TrackMetadata + try { + FileReader(files.metadataJson).use { reader -> + metadata = gson.fromJson( + reader, + TrackMetadata::class.java + ) + } + } catch (e: IOException) { + throw DownloaderException("deserialization of the metadata file failed", e) + } catch (e: JsonIOException) { + throw DownloaderException("deserialization of the metadata file failed", e) + } catch (e: JsonSyntaxException) { + throw DownloaderException("deserialization of the metadata file failed", e) + } + + // set track data + metadata.getTrackTitle()?.let { track.title = it } + metadata.getArtistName()?.let { track.artist = it } + metadata.getUploadDate()?.let { track.releaseDate = it } + metadata.duration?.let { track.duration = it } + metadata.album?.let { track.albumName = it } + } + + /** + * copy the temporary audio file to the final destination + * + * @param track the track to download + * @param files the temporary files, of which the audio file is copied to the downloads dir + * @param format the file format that was used for the download + * @throws DownloaderException if creating the final file or the copy operation fails + */ + @Throws(DownloaderException::class) + private fun copyAudioToFinal(track: TrackInfo, files: TempFiles, format: TrackDownloadFormat) { + // check audio file exists + if (!files.audio.exists()) + throw DownloaderException("cannot find audio file to copy") + + // find root folder for saving downloaded tracks to + // find using storage framework, and only allow persisted folders we can write to + val downloadRoot = downloadsDirectory + ?: throw DownloaderException("failed to find downloads folder") + + // create file to write the track to + val finalFile = + downloadRoot.createFile(format.mimetype, track.title + "." + format.fileExtension) + if (finalFile == null || !finalFile.canWrite()) + throw DownloaderException("Could not create final output file!") + + // copy the temp file to the final destination + try { + FileInputStream(files.audio).use { src -> + contentResolver.openOutputStream(finalFile.uri).use { out -> + src.copyTo(out!!) + } + } + } catch (e: IOException) { + // try to remove the final file + if (!finalFile.delete()) + Log.w(TAG, "failed to delete final file on copy fail") + + throw DownloaderException( + "error copying temp file (${files.audio}) to final destination (${finalFile.uri.toString()})", + e + ) + } + + // set the final file in track info + track.audioFileKey = finalFile.encodeToKey() + } + + /** + * copy the album cover to the final destination + * + * @param track the track to copy the cover of + * @param files the files downloaded by youtube-dl + * @throws DownloaderException if copying the cover fails + */ + @Throws(DownloaderException::class) + private fun copyCoverToFinal(track: TrackInfo, files: TempFiles) { + // check thumbnail file exists + val thumbnail = files.thumbnail + if (thumbnail == null || !thumbnail.exists()) + throw DownloaderException("cannot find thumbnail file") + + // get covers directory + val coverRoot = coverArtDirectory + + // create file for the thumbnail + val coverFile = File(coverRoot, "${track.id}_${generateRandomAlphaNumeric(64)}.webp") + + // read temporary thumbnail file and write as webp in cover art directory + try { + FileInputStream(thumbnail).use { src -> + FileOutputStream(coverFile).use { out -> + val cover = BitmapFactory.decodeStream(src) + cover.compress(Bitmap.CompressFormat.WEBP, 100, out) + cover.recycle() + } + } + } catch (e: IOException) { + throw DownloaderException("failed to save cover as webp", e) + } + + // set the cover file key in track + track.coverKey = DocumentFile.fromFile(coverFile).encodeToKey() + } + + /** + * write the track metadata to the id3v2 tag of the file + * + * @param track the track data + * @param files the files downloaded by youtube-dl + * @throws DownloaderException if writing the id3 tag fails + */ + @Throws(DownloaderException::class) + private fun writeID3Tag(track: TrackInfo, files: TempFiles) { + try { + // clear all previous id3 tags, and create a new & empty one + val mp3Wrapper = MP3agicWrapper(files.audio) + val tag = mp3Wrapper + .clearAllTags() + .tag + + // write basic metadata (title, artist, album, ...) + tag.title = track.title + track.artist?.let { tag.artist = it } + track.releaseDate?.let { tag.year = String.format(Locale.US, "%04d", it.year) } + track.albumName?.let { tag.album = it } + + // set cover art (if thumbnail was downloaded) + val thumbnail = files.thumbnail + if (thumbnail != null && thumbnail.exists()) { + try { + FileInputStream(thumbnail).use { src -> + ByteArrayOutputStream().use { out -> + // convert to png + val cover = BitmapFactory.decodeStream(src) + cover.compress(Bitmap.CompressFormat.PNG, 100, out) + cover.recycle() + + // write cover to tag + tag.setAlbumImage(out.toByteArray(), "image/png") + } + } + } catch (e: IOException) { + Log.e(TAG, "failed to convert cover image to PNG", e) + } + } + + // save the file with tags + mp3Wrapper.save() + } catch (e: IOException) { + throw DownloaderException("could not write id3v2 tag to file!", e) + } catch (e: NotSupportedException) { + throw DownloaderException("could not write id3v2 tag to file!", e) + } catch (e: InvalidDataException) { + throw DownloaderException("could not write id3v2 tag to file!", e) + } catch (e: UnsupportedTagException) { + throw DownloaderException("could not write id3v2 tag to file!", e) + } + } + + //region util + /** + * get the video url youtube-dl should use for a track + * + * @param track the track to get the video url of + * @return the video url + */ + private fun resolveVideoUrl(track: TrackInfo): String { + // youtube-dl is happy with just the track id + return track.id + } + + /** + * get the [Prefs.DownloadsDirectory] of the app, using storage framework + * + * @return the optional download root directory + */ + private val downloadsDirectory: DocumentFile? + get() { + val key = Prefs.DownloadsDirectory.get() + return if (key == StorageKey.EMPTY) null else key.getPersistedFilePermission(this, true) + } + + /** + * get the cover art directory + * + * @return the directory to save cover art to + * @throws DownloaderException if the directory could not be created + */ + @get:Throws(DownloaderException::class) + val coverArtDirectory: File + get() { + val coversDir = File(noBackupFilesDir, "cover_store") + if (!coversDir.exists() && !coversDir.mkdirs()) + throw DownloaderException("could not create cover_store directory") + + return coversDir + } + + /** + * get the youtube-dl cache directory + * + * @return the cache directory + * @throws DownloaderException if creating the directory failed + */ + @get:Throws(DownloaderException::class) + val downloadCacheDirectory: File + get() { + val cacheDir = File(cacheDir, "youtube-dl_cache") + if (!cacheDir.exists() && !cacheDir.mkdirs()) + throw DownloaderException("could not create youtube-dl_cache directory") + + return cacheDir + } + //endregion + //endregion + + //region status notification + /** + * update the progress notification + * + * @param newNotification the updated notification + */ + private fun updateNotification(newNotification: Notification) { + if (isInForeground) { + // already in foreground, update the notification + notificationManager.notify(PROGRESS_NOTIFICATION_ID, newNotification) + } else { + // create foreground notification + isInForeground = true + startForeground(PROGRESS_NOTIFICATION_ID, newNotification) + } + } + + /** + * cancel the progress notification and call [stopForeground] + */ + private fun hideNotification() { + notificationManager.cancel(PROGRESS_NOTIFICATION_ID) + stopForeground(true) + isInForeground = false + } + + /** + * create a download progress display notification (during track download) + * + * @param track the track that is being downloaded + * @param progress the current download progress, from 0.0 to 1.0 + * @param eta the estimated download time remaining, in seconds + * @return the progress notification + */ + private fun createProgressNotification( + track: TrackInfo, + progress: Double, + eta: Long + ): Notification { + return baseNotification + .setContentTitle(track.title) + .setSubText(getString(R.string.dl_notification_subtext, eta.secondsToTimeString())) + .setProgress(100, floor(progress * 100).toInt(), false) + .build() + } + + /** + * create a download prepare display notification (before or after track download) + * + * @param track the track that is being downloaded + * @param statusRes the status string + * @return the status notification + */ + private fun createStatusNotification( + track: TrackInfo, + @StringRes statusRes: Int + ): Notification { + return baseNotification + .setContentTitle(track.title) + .setSubText(getString(statusRes)) + .setProgress(1, 0, true) + .build() + } + + /** + * get the base notification, shared between all status notifications + * + * @return the builder, with base settings applied + */ //endregion + private val baseNotification: NotificationCompat.Builder + get() = NotificationCompat.Builder(this, NotificationChannels.DownloadProgress.id) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setShowWhen(false) + .setOnlyAlertOnce(true) +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloaderException.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloaderException.kt new file mode 100644 index 0000000..51ab713 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloaderException.kt @@ -0,0 +1,12 @@ +package io.github.shadow578.yodel.downloader + +import io.github.shadow578.music_dl.downloader.DownloaderService +import java.io.IOException + +/** + * exception used by [DownloaderService] + */ +class DownloaderException : IOException { + constructor(message: String) : super(message) + constructor(message: String, cause: Throwable) : super(message, cause) +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TempFiles.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TempFiles.kt new file mode 100644 index 0000000..5d68bfc --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TempFiles.kt @@ -0,0 +1,85 @@ +package io.github.shadow578.yodel.downloader + +import java.io.File + +/** + * temporary files created by youtube-dl + * + * @param tempFile the base file. this file is not used directly + * @param format the format of the output file + */ +class TempFiles(tempFile: File, format: String) { + companion object { + /** + * suffix for the metadata file + */ + private const val METADATA_FILE_SUFFIX = ".info.json" + + /** + * suffixes (file types) for the thumbnail file + */ + private val THUMBNAIL_FILE_SUFFIXES = arrayOf(".webp", ".webm", ".jpg", ".jpeg", ".png") + } + + /** + * the main audio file downloaded by youtube-dl. + * this file will be the same as [.convertedAudio], but with a .tmp extension + */ + private val downloadedAudio: File = File(tempFile.absolutePath + ".tmp") + + /** + * the converted audio file, created by ffmpeg with the --extract-audio option + */ + private val convertedAudio: File = File(tempFile.absolutePath + "." + format) + + /** + * delete all files + * + * @return did all deletes succeed? + */ + fun delete(): Boolean { + return (maybeDelete(downloadedAudio) + and maybeDelete(convertedAudio) + and maybeDelete(metadataJson) + and (thumbnail?.delete() == true)) + } + + /** + * delete the file if it still exists + * + * @param file the file to delete + * @return does the file no longer exist? + */ + private fun maybeDelete(file: File): Boolean { + return !file.exists() || file.delete() + } + + /** + * get the audio file. + * first tries to get [convertedAudio], if that does not exist gets [downloadedAudio] + */ + val audio: File + get() = if (convertedAudio.exists()) convertedAudio else downloadedAudio + + /** + * the metadata json downloaded by youtube-dl + */ + val metadataJson: File + get() = File(downloadedAudio.absolutePath + METADATA_FILE_SUFFIX) + + /** + * @return the thumbnail downloaded by youtube-dl, webp format + */ + val thumbnail: File? + get() { + // check all suffixes, use the first that exists + // youtube-dl downloads the thumbnail for us, but does not tell us the file name / type (with no way to tell it what to use :|) + for (suffix in THUMBNAIL_FILE_SUFFIXES) { + val thumbnailFile = File(downloadedAudio.absolutePath + suffix) + if (thumbnailFile.exists()) { + return thumbnailFile + } + } + return null + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackDownloadFormat.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackDownloadFormat.kt new file mode 100644 index 0000000..2f8e090 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackDownloadFormat.kt @@ -0,0 +1,50 @@ +package io.github.shadow578.yodel.downloader + +import androidx.annotation.StringRes +import io.github.shadow578.music_dl.R + +/** + * file formats for track download + * + * + * TODO validate all formats actually work + * TODO check if more formats support ID3 + */ +enum class TrackDownloadFormat( + val mimetype: String, + val fileExtension: String, + val supportsID3Tags: Boolean, + @StringRes val displayName: Int +) { + + + /** + * mp3 (with metadata in id3 tags) + */ + MP3("audio/mp3", "mp3", true, R.string.file_format_mp3), + + /** + * aac + */ + AAC("audio/aac", "aac", false, R.string.file_format_aac), + + /** + * webm audio + */ + WEBM("audio/weba", "weba", false, R.string.file_format_webm), + + /** + * ogg + */ + OGG("audio/ogg", "ogg", false, R.string.file_format_ogg), + + /** + * flac + */ + FLAC("audio/flac", "flac", false, R.string.file_format_flac), + + /** + * wav + */ + WAV("audio/wav", "wav", false, R.string.file_format_wav) +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackMetadata.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackMetadata.kt new file mode 100644 index 0000000..58cec35 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackMetadata.kt @@ -0,0 +1,162 @@ +package io.github.shadow578.yodel.downloader + +import com.google.gson.annotations.SerializedName +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +/** + * track metadata POJO. this is in the format that youtube-dl writes with the --write-info-json option + * a lot of data is left out, as it's not really relevant for what we're doing (stuff like track info, thumbnails, ...) + */ +data class TrackMetadata( + /** + * the full video title. for music videos, this often is in the format 'Artist - Song Title' + */ + @field:SerializedName("title") + val title: String? = null, + + /** + * the alternative video title. for music videos, this often is just the 'Song Title' (in contrast to [.title]). + * seems to be the same value as [.track] + */ + @field:SerializedName("alt_title") + val alt_title: String? = null, + + /** + * the upload date, in the format yyyyMMdd (that is without ANY spaces: 20200924 == 2020-09-24) + */ + @field:SerializedName("upload_date") + val upload_date: String? = null, + + /** + * the display name of the channel that uploaded the video + */ + @field:SerializedName("channel") + val channel: String? = null, + + /** + * the duration of the video, in seconds + */ + @field:SerializedName("duration") + val duration: Long? = null, + + /** + * the title of the track. this seems to be the same as [.alt_title] + */ + @field:SerializedName("track") + val track: String? = null, + + /** + * the name of the actual song creator (not uploader channel). + * This seems to be data from Content-ID + */ + @field:SerializedName("creator") + val creator: String? = null, + + /** + * the name of the actual song artist (not uploader channel). + * This seems to be data from Content-ID + */ + @field:SerializedName("artist") + val artist: String? = null, + + /** + * the display name of the album this track is from. + * only included for songs that are part of a album + */ + @field:SerializedName("album") + val album: String? = null, + + /** + * categories of the video (like 'Music', 'Entertainment', 'Gaming' ...) + */ + @field:SerializedName("categories") + val categories: List? = null, + + /** + * tags on the video + */ + @field:SerializedName("tags") + val tags: List? = null, + + /** + * total view count of the video + */ + @field:SerializedName("view_count") + val view_count: Long? = null, + + /** + * total likes on the video + */ + @field:SerializedName("like_count") + val like_count: Long? = null, + + /** + * total dislikes on the video + */ + @field:SerializedName("dislike_count") + val dislike_count: Long? = null, + + /** + * the average video like/dislike rating. + * range seems to be 0-5 + */ + @field:SerializedName("average_rating") + val average_rating: Double? = null +) { + /** + * get the track title. tries the following fields (in that order): + * - [track] + * - [alt_title] + * - [title] + * + * @return the track title + */ + fun getTrackTitle(): String? { + if (!track.isNullOrBlank()) return track + if (!alt_title.isNullOrBlank()) return alt_title + if (!title.isNullOrBlank()) return title + return null + } + + /** + * get the name of the primary song artist. tries the following fields (in that order): + * - [artist] (first entry) + * - [creator] (first entry) + * - [channel] + * + * @return the artist name + */ + fun getArtistName(): String? { + // try to use artist OR creator + val artistList = if (artist.isNullOrBlank()) artist else creator + if (!artistList.isNullOrBlank()) { + // if artistList is a list, only take the first artist + val firstComma = artistList.indexOf(',') + return if (firstComma > 0) artistList.substring(0, firstComma) else artistList + } + + // fallback to channel + return channel + } + + /** + * parse the [upload_date] into a normal format + * + * @return the parsed date + */ + fun getUploadDate(): LocalDate? { + return if (upload_date.isNullOrBlank()) { + // no value for the field + null + } else try { + // parse + val format = DateTimeFormatter.ofPattern("yyyyMMdd") + LocalDate.parse(upload_date, format) + } catch (ignored: DateTimeParseException) { + // parse failed + null + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/MP3agicWrapper.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/MP3agicWrapper.kt new file mode 100644 index 0000000..9930652 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/MP3agicWrapper.kt @@ -0,0 +1,88 @@ +package io.github.shadow578.yodel.downloader.wrapper + +import android.util.Log +import com.mpatric.mp3agic.ID3v2 +import com.mpatric.mp3agic.ID3v24Tag +import com.mpatric.mp3agic.Mp3File +import com.mpatric.mp3agic.NotSupportedException +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException + +/** + * wrapper for MP3agic to make working with it on android easier + * + * @param file the mp3 file to tag + */ +class MP3agicWrapper( + private val file: File +) { + /** + * tag for logging + */ + private val TAG = "MP3agicW" + + /** + * the mp3agic file instance + */ + private val mp3: Mp3File = Mp3File(file) + + /** + * remove all tags that mp3agic supports + * + * @return self instance + */ + fun clearAllTags(): MP3agicWrapper { + if (mp3.hasId3v1Tag()) + mp3.removeId3v1Tag() + if (mp3.hasId3v2Tag()) + mp3.removeId3v2Tag() + if (mp3.hasCustomTag()) + mp3.removeCustomTag() + return this + } + + /** + * edit the id3v2 tags on the mp3 file. + * gets a existing id3v2 tag, or creates a new one if needed + */ + val tag: ID3v2 + get() = if (mp3.hasId3v2Tag()) mp3.id3v2Tag else { + val tag = ID3v24Tag() + mp3.id3v2Tag = tag + tag + } + + /** + * save the mp3 file, overwriting the original file + * + * @throws IOException if io operation fails + * @throws NotSupportedException if mp3agic failes to save the file (see [Mp3File.save]) + */ + @Throws(IOException::class, NotSupportedException::class) + fun save() { + var tagged: File? = null + try { + // create file to write to (original appended with .tagged) + tagged = File(file.absolutePath + ".tagged") + + // save mp3 to tagged file + mp3.save(tagged.absolutePath) + + // delete original file + if (!file.delete()) + Log.i(TAG, "could not delete original file on save!") + + // move tagged file to its place + FileInputStream(tagged.absolutePath).use { src -> + FileOutputStream(file.absolutePath, false).use { out -> + src.copyTo(out) + } + } + } finally { + if (tagged != null && tagged.exists() && !tagged.delete()) + Log.i(TAG, "failed to delete temporary tagged mp3 file") + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapper.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapper.kt new file mode 100644 index 0000000..b21fb4b --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapper.kt @@ -0,0 +1,266 @@ +package io.github.shadow578.yodel.downloader.wrapper + +import android.content.Context +import android.util.Log +import com.yausername.ffmpeg.FFmpeg +import com.yausername.youtubedl_android.* +import io.github.shadow578.music_dl.BuildConfig +import java.io.File + +/** + * wrapper for [com.yausername.youtubedl_android.YoutubeDL]. + * all functions in this class should be run in a background thread only + * + * @param videoUrl the video url to download + */ +class YoutubeDLWrapper( + private val videoUrl: String +) { + companion object { + /** + * tag for logging + */ + private const val TAG = "Youtube-DL" + + /** + * did the YoutubeDl library initialize once? in [.init] + */ + private var initialized = false + + /** + * initialize the youtube-dl library + * + * @param ctx the context to work in + * @return did initialization succeed + */ + fun init(ctx: Context): Boolean { + // only once + if (initialized) return true + initialized = true + + return try { + // initialize and update youtube-dl + YoutubeDL.getInstance().init(ctx) + YoutubeDL.getInstance().updateYoutubeDL(ctx) + + // initialize FFMPEG library + FFmpeg.getInstance().init(ctx) + true + } catch (e: YoutubeDLException) { + Log.e(TAG, "youtube-dl init failed", e) + initialized = false + false + } + } + } + + /** + * the download request + */ + val request: YoutubeDLRequest = YoutubeDLRequest(videoUrl) + + /** + * should the command output be printed to log? + */ + private var printOutput = false + + init { + // enable verbose output on debug builds + if (BuildConfig.DEBUG) { + request.addOption("--verbose") + } + printOutput(BuildConfig.DEBUG) + } + + //region parameter wrapper + /** + * make youtube-dl overwrite existing files, using the '--no-continue' option. + * only for use with [.download] functions + * + * @return self instance + */ + fun overwriteExisting(): YoutubeDLWrapper { + request.addOption("--no-continue") + return this + } + + /** + * (try) to fix ssl certificate validation errors, using the '--no-check-certificate' and '--prefer-insecure' options. + * + * @return self instance + */ + fun fixSsl(): YoutubeDLWrapper { + request.addOption("--no-check-certificate") + .addOption("--prefer-insecure") + return this + } + + /** + * download audio and video in the best quality, using '-f best'. + * only for use with [.download] functions + * + * @return self instance + */ + fun audioAndVideo(): YoutubeDLWrapper { + request.addOption("-f", "best") + return this + } + + /** + * download best quality video only, using '-f bestvideo'. + * only for use with [.download] functions + * + * @return self instance + */ + fun videoOnly(): YoutubeDLWrapper { + request.addOption("-f", "bestvideo") + return this + } + + /** + * download best quality audio only, using '-f bestaudio' with '--extract-audio'. '--audio-quality 0' and '--audio-format FORMAT'. + * only for use with [.download] functions + * + * @param format the format of the audio to download, like 'mp3' + * @return self instance + */ + fun audioOnly(format: String): YoutubeDLWrapper { + request.addOption("-f", "bestaudio").addOption("--extract-audio") + .addOption("--audio-format", format) + .addOption("--audio-quality", 0) + return this + } + + /** + * write the metadata to disk as a json file. path is [.output] + .info.json + * + * @return self instance + */ + fun writeMetadata(): YoutubeDLWrapper { + request.addOption("--write-info-json") + return this + } + + /** + * write the main thumbnail to disk as webp file. path is [.output] + .webp + * + * @return self instance + */ + fun writeThumbnail(): YoutubeDLWrapper { + request.addOption("--write-thumbnail") + return this + } + + /** + * set the file to download to, using '-o OUTPUT'. + * only for use with [.download] functions + * + * @param output the file to output to. unless called with [.overwriteExisting], this file must not exist + * @return self instance + */ + fun output(output: File): YoutubeDLWrapper { + request.addOption("-o", output.absolutePath) + return this + } + + /** + * set the youtube-dl cache directory, using '-cache-dir CACHE'. + * + * @param cache the cache directory + * @return self instance + */ + fun cacheDir(cache: File): YoutubeDLWrapper { + request.addOption("--cache-dir", cache.absolutePath) + return this + } + + /** + * set a option. + * only for use with [.download] functions + * + * @param key the parameter name (eg. '-f') + * @param value the parameter value (eg. 'best'). this may be null for options without value (like '--continue') + * @return self instance + */ + fun setOption(key: String, value: String?): YoutubeDLWrapper { + if (value == null) { + request.addOption(key) + } else { + request.addOption(key, value) + } + return this + } + + /** + * enable printing of the youtube-dl command output. + * by default on on DEBUG builds, and off on RELEASE builds. + * only for use with [.download] functions + * + * @return self instance + */ + fun printOutput(print: Boolean): YoutubeDLWrapper { + printOutput = print + return this + } + //endregion + + //region download + /** + * download the video using youtube-dl, with retires + * + * @param progressCallback callback to report back download progress + * @param tries the number of retries for downloading + * @return the response, or null if the download failed + */ + fun download(progressCallback: DownloadProgressCallback?, tries: Int = 1): YoutubeDLResponse? { + var retry = tries + check(initialized) { "youtube-dl was not initialized! call YoutubeDLWrapper.init() first!" } + var response: YoutubeDLResponse? + do { + response = download(progressCallback) + if (response != null) { + break + } + } while (--retry > 0) + return response + } + + /** + * download the video using youtube-dl, without retires + * + * @param progressCallback callback to report back download progress + * @return the response, or null if the download failed + */ + fun download(progressCallback: DownloadProgressCallback?): YoutubeDLResponse? { + check(initialized) { "youtube-dl was not initialized! call YoutubeDLWrapper.init() first!" } + return try { + Log.i(TAG, "downloading $videoUrl") + val response = YoutubeDL.getInstance().execute(request, progressCallback) + if (printOutput) { + print(response) + } + response + } catch (e: YoutubeDLException) { + Log.e(TAG, "download of '$videoUrl' using youtube-dl failed", e) + null + } catch (e: InterruptedException) { + Log.e(TAG, "download of '$videoUrl' using youtube-dl failed", e) + null + } + } + + /** + * print response details to log + * + * @param response the response to print + */ + private fun print(response: YoutubeDLResponse) { + Log.i(TAG, "-------------") + Log.i(TAG, " url: $videoUrl") + Log.i(TAG, " command: ${response.command}") + Log.i(TAG, " exit code: ${response.exitCode}") + Log.i(TAG, " stdout: ${response.out}") + Log.i(TAG, " stderr: ${response.err}") + } +//endregion +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/InsertTrackUIHelper.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/InsertTrackUIHelper.kt new file mode 100644 index 0000000..6687c77 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/InsertTrackUIHelper.kt @@ -0,0 +1,87 @@ +package io.github.shadow578.yodel.ui + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import io.github.shadow578.music_dl.R +import io.github.shadow578.music_dl.util.Async +import io.github.shadow578.yodel.db.TracksDB +import io.github.shadow578.yodel.db.model.TrackInfo +import io.github.shadow578.yodel.util.launchIO +import io.github.shadow578.yodel.util.launchMain + +/** + * helper class for inserting tracks into the db from UI + */ +object InsertTrackUIHelper { + /** + * insert a new, not yet downloaded track into the db. + * if the track already exists, displays a dialog to replace it + * + * @param ctx the context to work in + * @param id the track id + * @param title the track title + */ + fun insertTrack(ctx: Context, id: String, title: String?) { + var trackTitle = title + val fallbackTitle = ctx.getString(R.string.fallback_title) + if (trackTitle == null || trackTitle.isEmpty()) { + trackTitle = fallbackTitle + } + + launchIO { + // check if track already in db + // if yes, show a dialog prompting the user to replace the existing track + val existingTrack = TracksDB.get(ctx).tracks()[id] + if (existingTrack != null) { + launchMain { + showReplaceDialog( + ctx, + id, + existingTrack.title + ) + } + } else { + insert(ctx, id, trackTitle) + } + } + } + + /** + * show a dialog prompting the user if he wants to replace a existing track + * has to be called on main ui thread + * + * @param ctx the context to work in + * @param id the track id + * @param title the track title + */ + private fun showReplaceDialog(ctx: Context, id: String, title: String) { + AlertDialog.Builder(ctx) + .setTitle(R.string.tracks_replace_existing_title) + .setMessage(ctx.getString(R.string.tracks_replace_existing_message, title)) + .setPositiveButton(R.string.tracks_replace_existing_positive) { dialog, _ -> + Async.runAsync { + insert( + ctx, + id, + title + ) + } + dialog.dismiss() + } + .setNegativeButton(R.string.tracks_replace_existing_negative) { dialog, _ -> dialog.dismiss() } + .create() + .show() + } + + /** + * insert a new, not yet downloaded track into the db. + * has to be called on a background thread + * + * @param ctx the context to work in + * @param id the track id + * @param title the track title + */ + private fun insert(ctx: Context, id: String, title: String) { + TracksDB.get(ctx).tracks().insert(TrackInfo(id, title)) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/base/BaseActivity.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/base/BaseActivity.kt new file mode 100644 index 0000000..24e6ca2 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/base/BaseActivity.kt @@ -0,0 +1,94 @@ +package io.github.shadow578.yodel.ui.base + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree +import androidx.appcompat.app.AppCompatActivity +import androidx.documentfile.provider.DocumentFile +import io.github.shadow578.music_dl.R +import io.github.shadow578.yodel.downloader.DownloaderService +import io.github.shadow578.yodel.util.preferences.Prefs +import io.github.shadow578.yodel.util.storage.StorageKey +import io.github.shadow578.yodel.util.storage.getPersistedFilePermission +import io.github.shadow578.yodel.util.storage.persistFilePermission +import io.github.shadow578.yodel.util.wrapLocale + +/** + * topmost base activity. this is to be extended when creating a new activity. + * handles app- specific stuff + */ +open class BaseActivity : AppCompatActivity() { + /** + * result launcher for download directory select + */ + private lateinit var downloadDirectorySelectLauncher: ActivityResultLauncher + + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(newBase.wrapLocale()) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // create result launcher for download directory select + downloadDirectorySelectLauncher = + registerForActivityResult(OpenDocumentTree()) { treeUri: Uri? -> + if (treeUri != null) { + val treeFile = DocumentFile.fromTreeUri(this, treeUri) + + // check access + if (treeFile != null && treeFile.exists() + && treeFile.canRead() + && treeFile.canWrite() + ) { + // persist the permission & save + val treeKey = treeUri.persistFilePermission(applicationContext) + Prefs.DownloadsDirectory.set(treeKey) + Log.i("Yodel", "selected and saved new track downloads directory: $treeUri") + + // restart downloader + val serviceIntent = Intent(application, DownloaderService::class.java) + application.startService(serviceIntent) + } else { + // bad selection + Toast.makeText( + this, + R.string.base_toast_set_download_directory_fail, + Toast.LENGTH_LONG + ).show() + maybeSelectDownloadsDir(true) + } + } + } + } + + /** + * prompt the user to select the downloads dir, if not set + * + * @param force force select a new directory? + */ + fun maybeSelectDownloadsDir(force: Boolean) { + // check if downloads dir is set and accessible + val downloadsKey = Prefs.DownloadsDirectory.get() + if (downloadsKey != StorageKey.EMPTY && !force) { + val downloadsDir = downloadsKey.getPersistedFilePermission(this, true) + if (downloadsDir != null + && downloadsDir.exists() + && downloadsDir.canWrite() + ) { + // download directory exists and can write, all OK! + return + } + } + + // select directory + Toast.makeText(this, R.string.base_toast_select_download_directory, Toast.LENGTH_LONG) + .show() + downloadDirectorySelectLauncher.launch(null) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/base/BaseFragment.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/base/BaseFragment.kt new file mode 100644 index 0000000..35daa05 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/base/BaseFragment.kt @@ -0,0 +1,8 @@ +package io.github.shadow578.yodel.ui.base + +import androidx.fragment.app.Fragment + +/** + * base fragment, with some common functionality + */ +open class BaseFragment : Fragment() \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/main/MainActivity.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/main/MainActivity.kt new file mode 100644 index 0000000..8526620 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/main/MainActivity.kt @@ -0,0 +1,139 @@ +package io.github.shadow578.yodel.ui.main + +import android.os.Bundle +import androidx.annotation.IdRes +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModelProvider +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback +import io.github.shadow578.music_dl.R +import io.github.shadow578.music_dl.databinding.ActivityMainBinding +import io.github.shadow578.yodel.ui.base.BaseActivity +import io.github.shadow578.yodel.ui.more.MoreFragment +import io.github.shadow578.yodel.ui.tracks.TracksFragment +import java.util.* + +/** + * the main activity + */ +class MainActivity : BaseActivity() { + private val tracksFragment = TracksFragment() + private val moreFragment = MoreFragment() + + /** + * order of the sections + */ + private val sectionOrder = listOf( + Section.Tracks, + Section.More + ) + + /** + * the view model instance + */ + private lateinit var model: MainViewModel + + /** + * the view binding instance + */ + private lateinit var b: ActivityMainBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + b = ActivityMainBinding.inflate(layoutInflater) + setContentView(b.root) + + // create model + model = ViewModelProvider(this).get(MainViewModel::class.java) + + // init UI + setupBottomNavigationAndPager() + + // select downloads dir + maybeSelectDownloadsDir(false) + } + + /** + * set up the fragment view pager and bottom navigation so they work + * together with each other & the view model + */ + private fun setupBottomNavigationAndPager() { + b.fragmentPager.adapter = SectionAdapter(this) + + // setup bottom navigation listener to update model + b.bottomNav.setOnItemSelectedListener { item -> + // find section with matching id + for (section in Section.values()) { + if (section.menuItemId == item.itemId) { + model.switchToSection(section) + return@setOnItemSelectedListener true + } + } + true + } + + // setup viewpager listener to update model + b.fragmentPager.registerOnPageChangeCallback(object : OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + model.switchToSection(sectionOrder[position]) + } + }) + + // sync model with pager and bottom navigation + model.section.observe(this, { section: Section -> + b.bottomNav.selectedItemId = section.menuItemId + b.fragmentPager.currentItem = sectionOrder.indexOf(section) + b.fragmentPager.isUserInputEnabled = section.allowPagerInput + }) + } + + /** + * get the fragment instance by section name + * + * @param section the section name + * @return the fragment + */ + private fun getSectionFragment(section: Section): Fragment { + return when (section) { + Section.Tracks -> tracksFragment + Section.More -> moreFragment + } + } + + /** + * fragments / sections of the main activity + * + * @param menuItemId the menu item of this section + * @param allowPagerInput allow user input on the view pager? + */ + enum class Section( + @field:IdRes + @param:IdRes val menuItemId: Int, + val allowPagerInput: Boolean + ) { + /** + * the tracks library fragment + */ + Tracks(R.id.nav_tracks, true), + + /** + * the more / about fragment + */ + More(R.id.nav_more, true); + } + + /** + * adapter for the view pager + */ + private inner class SectionAdapter(fragmentActivity: FragmentActivity) : + FragmentStateAdapter(fragmentActivity) { + override fun createFragment(position: Int): Fragment { + return getSectionFragment(sectionOrder[position]) + } + + override fun getItemCount(): Int { + return sectionOrder.size + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/main/MainViewModel.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/main/MainViewModel.kt new file mode 100644 index 0000000..40a62b9 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/main/MainViewModel.kt @@ -0,0 +1,49 @@ +package io.github.shadow578.yodel.ui.main + +import android.app.Application +import android.content.Intent +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.github.shadow578.yodel.downloader.DownloaderService + +/** + * view model for the main activity + */ +class MainViewModel(application: Application) : AndroidViewModel(application) { + init { + startDownloadService() + } + + /** + * the currently open section + */ + private val currentSection = MutableLiveData(MainActivity.Section.Tracks) + + /** + * start the downloader service + */ + private fun startDownloadService() { + val serviceIntent = Intent(getApplication(), DownloaderService::class.java) + getApplication().startService(serviceIntent) + } + + /** + * @return the currently visible section + */ + val section: LiveData + get() = currentSection + + /** + * switch the currently active section + * + * @param section the section to switch to + */ + fun switchToSection(section: MainActivity.Section) { + // ignore if same section + if (section == currentSection.value) { + return + } + currentSection.value = section + } +} diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreFragment.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreFragment.kt new file mode 100644 index 0000000..9bf6d09 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreFragment.kt @@ -0,0 +1,222 @@ +package io.github.shadow578.yodel.ui.more + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts.CreateDocument +import androidx.activity.result.contract.ActivityResultContracts.OpenDocument +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.ViewModelProvider +import io.github.shadow578.music_dl.R +import io.github.shadow578.music_dl.databinding.FragmentMoreBinding +import io.github.shadow578.yodel.LocaleOverride +import io.github.shadow578.yodel.downloader.TrackDownloadFormat +import io.github.shadow578.yodel.ui.base.BaseFragment +import java.util.* +import java.util.stream.Collectors + +/** + * more / about fragment + */ +class MoreFragment : BaseFragment() { + /** + * view binding instance + */ + private lateinit var b: FragmentMoreBinding + + /** + * view model instance + */ + private lateinit var model: MoreViewModel + + /** + * launcher for export file choose action + */ + private lateinit var chooseExportFileLauncher: ActivityResultLauncher + + /** + * launcher for import file choose action + */ + private lateinit var chooseImportFileLauncher: ActivityResultLauncher> + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + b = FragmentMoreBinding.inflate(inflater, container, false) + return b.root + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + chooseExportFileLauncher = registerForActivityResult(CreateDocument()) { uri: Uri? -> + if (uri == null) { + return@registerForActivityResult + } + + val file = DocumentFile.fromSingleUri(requireContext(), uri) + if (file != null && file.canWrite()) { + model.exportTracks(file) + Toast.makeText( + requireContext(), + R.string.backup_toast_starting, + Toast.LENGTH_SHORT + ).show() + } else { + Toast.makeText( + requireContext(), + R.string.backup_toast_failed, + Toast.LENGTH_SHORT + ).show() + } + } + + chooseImportFileLauncher = registerForActivityResult( + OpenDocument() + ) { uri: Uri? -> + if (uri == null) { + return@registerForActivityResult + } + val file = DocumentFile.fromSingleUri(requireContext(), uri) + if (file != null && file.canRead()) { + model.importTracks(file, requireActivity()) + Toast.makeText( + requireContext(), + R.string.restore_toast_starting, + Toast.LENGTH_SHORT + ).show() + } else { + Toast.makeText( + requireContext(), + R.string.restore_toast_failed, + Toast.LENGTH_SHORT + ).show() + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + model = ViewModelProvider(requireActivity()).get( + MoreViewModel::class.java + ) + + // about button + b.about.setOnClickListener { model.openAboutPage(requireActivity()) } + + // select downloads dir + b.selectDownloadsDir.setOnClickListener { model.chooseDownloadsDir(requireActivity()) } + + // populate language selection + setupLanguageSelection() + + // populate download formats + setupFormatSelection() + + // listen to ssl fix + b.enableSslFix.setOnCheckedChangeListener { _, isChecked -> + model.setEnableSSLFix(isChecked) + } + + model.enableSSLFix.observe(requireActivity(), + { sslFix: Boolean -> + b.enableSslFix.isChecked = sslFix + }) + + // listen to write metadata + b.enableTagging.setOnCheckedChangeListener { _, isChecked -> + model.setEnableTagging(isChecked) + } + model.enableTagging.observe(requireActivity(), + { enableTagging: Boolean -> + b.enableTagging.isChecked = enableTagging + }) + + // backup / restore buttons + b.restoreTracks.setOnClickListener { + chooseImportFileLauncher.launch(arrayOf("application/json")) + } + b.backupTracks.setOnClickListener { chooseExportFileLauncher.launch("tracks_export.json") } + } + + /** + * setup the download format selector + */ + private fun setupFormatSelection() { + // create a list of the formats and a list of display names + // both lists are in the same order + val ctx = requireContext() + val formatValues = listOf(*TrackDownloadFormat.values()) + val formatDisplayNames = formatValues.stream() + .map { ctx.getString(it.displayName) } + .collect(Collectors.toList()) + + // set values to display + val adapter = + ArrayAdapter(ctx, android.R.layout.simple_dropdown_item_1line, formatDisplayNames) + b.downloadsFormat.setAdapter(adapter) + + // set change listener + b.downloadsFormat.setOnItemClickListener { _, _, position, _ -> + model.setDownloadFormat(formatValues[position]) + } + + // sync with model + model.downloadFormat.observe( + requireActivity(), + { + val i = formatValues.indexOf(it) + b.downloadsFormat.setText(formatDisplayNames[i], false) + }) + + // always show all items + b.downloadsFormat.setOnClickListener { + adapter.filter.filter(null) + b.downloadsFormat.showDropDown() + } + } + + /** + * setup the language override selector + */ + private fun setupLanguageSelection() { + // create a list of the locale overrides and a list of display names + // both lists are in the same order + val ctx = requireContext() + val localeValues = listOf(*LocaleOverride.values()) + val localeDisplayNames = localeValues.stream() + .map { it.getDisplayName(ctx) } + .collect(Collectors.toList()) + + // set values to display + val adapter = + ArrayAdapter(ctx, android.R.layout.simple_dropdown_item_1line, localeDisplayNames) + b.languageOverride.setAdapter(adapter) + + // set change listener + b.languageOverride.setOnItemClickListener { _, _, position, _ -> + val changed = model.setLocaleOverride(localeValues[position]) + if (changed) { + requireActivity().recreate() + } + } + + // sync with model + model.localeOverride.observe(requireActivity(), { localeOverride: LocaleOverride -> + val i = localeValues.indexOf(localeOverride) + b.languageOverride.setText(localeDisplayNames[i], false) + }) + + // always show all items + b.languageOverride.setOnClickListener { v -> + adapter.filter.filter(null) + b.languageOverride.showDropDown() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreViewModel.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreViewModel.kt new file mode 100644 index 0000000..cfa24c7 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreViewModel.kt @@ -0,0 +1,205 @@ +package io.github.shadow578.yodel.ui.more + +import android.app.Activity +import android.app.Application +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import com.mikepenz.aboutlibraries.LibsBuilder +import io.github.shadow578.music_dl.R +import io.github.shadow578.music_dl.util.Async +import io.github.shadow578.yodel.LocaleOverride +import io.github.shadow578.yodel.backup.BackupHelper +import io.github.shadow578.yodel.downloader.TrackDownloadFormat +import io.github.shadow578.yodel.ui.base.BaseActivity +import io.github.shadow578.yodel.util.launchIO +import io.github.shadow578.yodel.util.launchMain +import io.github.shadow578.yodel.util.preferences.Prefs +import java.util.concurrent.atomic.AtomicBoolean + +/** + * view model for more fragment + */ +class MoreViewModel(application: Application) : AndroidViewModel(application) { + /** + * currently selected download format + */ + val downloadFormat = MutableLiveData(Prefs.DownloadFormat.get()) + + /** + * current state of ssl_fix enable + */ + val enableSSLFix = MutableLiveData(Prefs.EnableSSLFix.get()) + + /** + * current state of metadata tagging enable + */ + val enableTagging = MutableLiveData(Prefs.EnableMetadataTagging.get()) + + /** + * currently selected locale override + */ + val localeOverride = MutableLiveData(Prefs.AppLocaleOverride.get()) + + /** + * open the about page + * + * @param activity parent activity + */ + fun openAboutPage(activity: Activity) { + val libs = LibsBuilder() + libs.aboutAppName = activity.getString(R.string.app_name) + libs.start(activity) + } + + /** + * choose the download directory + * + * @param activity parent activity + */ + fun chooseDownloadsDir(activity: Activity) { + if (activity is BaseActivity) + activity.maybeSelectDownloadsDir(true) + } + + /** + * import tracks from a backup file + * + * @param file the file to import from + */ + fun importTracks(file: DocumentFile, parent: Activity) { + launchIO { + // read the backup data + val backup = BackupHelper.readBackupData(getApplication(), file) + if (!backup.isPresent) { + Async.runOnMain { + Toast.makeText( + getApplication(), + R.string.restore_toast_failed, + Toast.LENGTH_SHORT + ).show() + } + return@launchIO + } + + // show confirmation dialog + launchMain { + val replaceExisting = AtomicBoolean(false) + val tracksCount = backup.get().tracks.size + AlertDialog.Builder(parent) + .setTitle( + getApplication().getString( + R.string.restore_dialog_title, + tracksCount + ) + ) + .setSingleChoiceItems( + R.array.restore_dialog_modes, + 0 + ) { _, mode -> replaceExisting.set(mode == 1) } + .setNegativeButton(R.string.restore_dialog_negative) { dialog, _ -> dialog.dismiss() } + .setPositiveButton(R.string.restore_dialog_positive) { _, _ -> + // restore the backup + Toast.makeText( + getApplication(), + getApplication().getString( + R.string.restore_toast_success, + tracksCount + ), + Toast.LENGTH_SHORT + ).show() + + launchIO { + BackupHelper.restoreBackup( + getApplication(), + backup.get(), + replaceExisting.get() + ) + } + } + .show() + } + } + } + + /** + * export tracks to a backup file + * + * @param file the file to export to + */ + fun exportTracks(file: DocumentFile) { + launchIO { + val success = + BackupHelper.createBackup( + getApplication(), + file + ) + if (!success) { + launchMain { + Toast.makeText( + getApplication(), + R.string.backup_toast_failed, + Toast.LENGTH_SHORT + ).show() + } + } + } + } + + /** + * set the download format + * + * @param format new download format + */ + fun setDownloadFormat(format: TrackDownloadFormat) { + // ignore if no change + if (format == downloadFormat.value) { + return + } + Prefs.DownloadFormat.set(format) + downloadFormat.value = format + } + + /** + * set ssl fix enable + * + * @param enable enable ssl fix? + */ + fun setEnableSSLFix(enable: Boolean) { + if (java.lang.Boolean.valueOf(enable) == enableSSLFix.value) { + return + } + Prefs.EnableSSLFix.set(enable) + enableSSLFix.value = enable + } + + /** + * enable or disable metadata tagging + * + * @param enable is tagging enabled? + */ + fun setEnableTagging(enable: Boolean) { + if (enable == enableTagging.value) { + return + } + Prefs.EnableMetadataTagging.set(enable) + enableTagging.value = enable + } + + /** + * set the currently selected locale override + * + * @param localeOverride the currently selected locale override + * @return was the locale changed? + */ + fun setLocaleOverride(localeOverride: LocaleOverride): Boolean { + if (localeOverride == this.localeOverride.value) + return false + + Prefs.AppLocaleOverride.set(localeOverride) + this.localeOverride.value = localeOverride + return true + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/share/ShareTargetActivity.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/share/ShareTargetActivity.kt new file mode 100644 index 0000000..9a95057 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/share/ShareTargetActivity.kt @@ -0,0 +1,68 @@ +package io.github.shadow578.yodel.ui.share + +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import io.github.shadow578.music_dl.R +import io.github.shadow578.yodel.downloader.DownloaderService +import io.github.shadow578.yodel.ui.InsertTrackUIHelper +import io.github.shadow578.yodel.util.extractTrackId + +/** + * activity that handles shared youtube links (for download) + */ +class ShareTargetActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val intent = intent + + // action is SHARE + if (intent != null && Intent.ACTION_SEND == intent.action) { + if (handleShare(intent)) + Toast.makeText(this, R.string.share_toast_ok, Toast.LENGTH_SHORT).show() + else + Toast.makeText(this, R.string.share_toast_fail, Toast.LENGTH_SHORT).show() + } + + // done + finish() + } + + /** + * handle a shared video url + * + * @param intent the share intent (with ACTION_SEND) + * @return could the url be handled successfully? + */ + private fun handleShare(intent: Intent): Boolean { + // type is text/plain + if (!"text/plain".equals(intent.type, ignoreCase = true)) + return false + + // has EXTRA_TEXT + if (!intent.hasExtra(Intent.EXTRA_TEXT)) + return false + + // get shared url from text + val url = intent.getStringExtra(Intent.EXTRA_TEXT) + if (url == null || url.isEmpty()) + return false + + // get video ID + val trackId = extractTrackId(url) ?: return false + + // get title if possible + var title: String? = null + if (intent.hasExtra(Intent.EXTRA_TITLE)) + title = intent.getStringExtra(Intent.EXTRA_TITLE) + + // add to db as pending download + InsertTrackUIHelper.insertTrack(this, trackId, title) + + // start downloader as needed + val serviceIntent = Intent(this, DownloaderService::class.java) + startService(serviceIntent) + return true + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/splash/SplashScreenActivity.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/splash/SplashScreenActivity.kt new file mode 100644 index 0000000..cbe8ce6 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/splash/SplashScreenActivity.kt @@ -0,0 +1,23 @@ +package io.github.shadow578.yodel.ui.splash + +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import io.github.shadow578.music_dl.ui.main.MainActivity +import io.github.shadow578.yodel.util.launchMain +import kotlinx.coroutines.delay + +/** + * basic splash- screen activity. + * displays a splash- screen, then redirects the user to the correct activity + */ +class SplashScreenActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + launchMain { + delay(50) + startActivity(Intent(this@SplashScreenActivity, MainActivity::class.java)) + finish() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksAdapter.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksAdapter.kt new file mode 100644 index 0000000..e705718 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksAdapter.kt @@ -0,0 +1,209 @@ +package io.github.shadow578.yodel.ui.tracks + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.DrawableRes +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import io.github.shadow578.music_dl.R +import io.github.shadow578.music_dl.databinding.RecyclerTrackViewBinding +import io.github.shadow578.yodel.db.model.TrackInfo +import io.github.shadow578.yodel.db.model.TrackStatus +import io.github.shadow578.yodel.util.launchMain +import io.github.shadow578.yodel.util.secondsToTimeString +import io.github.shadow578.yodel.util.storage.decodeToUri +import kotlinx.coroutines.delay +import java.util.* + +/** + * recyclerview adapter for tracks livedata + */ +class TracksAdapter( + owner: LifecycleOwner, + tracks: LiveData>, + private val clickListener: ItemListener, + private val reDownloadListener: ItemListener +) : RecyclerView.Adapter() { + + init { + tracks.observe(owner, { trackInfoList: List -> + this.tracks = trackInfoList + notifyDataSetChanged() + }) + } + + private var tracks: List = ArrayList() + + /** + * items that should be removed later. + * key is item position, value if remove was aborted + */ + private val itemsToDelete = HashMap() + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { + return Holder( + RecyclerTrackViewBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onBindViewHolder(holder: Holder, position: Int) { + val track = tracks[position] + + // cover + val coverUri = track.coverKey.decodeToUri() + if (coverUri != null) { + // load cover from fs using glide + Glide.with(holder.b.coverArt) + .load(coverUri) + .placeholder(R.drawable.ic_splash_foreground) + .fallback(R.drawable.ic_splash_foreground) + .into(holder.b.coverArt) + } else { + // load fallback image + Glide.with(holder.b.coverArt) + .load(R.drawable.ic_splash_foreground) + .into(holder.b.coverArt) + } + + // title + holder.b.title.text = track.title + + // build and set artist + album + val albumAndArtist: String? = + if (track.artist != null && track.albumName != null) { + holder.b.root.context.getString( + R.string.tracks_artist_and_album, + track.artist, + track.albumName + ) + } else if (track.artist != null) { + track.artist + } else if (track.albumName != null) { + track.albumName + } else { + null + } + holder.b.albumAndArtist.text = albumAndArtist ?: "" + + // status icon + @DrawableRes val statusDrawable: Int = when (track.status) { + TrackStatus.DownloadPending -> R.drawable.ic_round_timer_24 + TrackStatus.Downloading -> R.drawable.ic_downloading_black_24dp + TrackStatus.Downloaded -> R.drawable.ic_round_check_circle_outline_24 + TrackStatus.DownloadFailed -> R.drawable.ic_round_error_outline_24 + TrackStatus.FileDeleted -> R.drawable.ic_round_remove_circle_outline_24 + } + holder.b.statusIcon.setImageResource(statusDrawable) + + // retry download button + val canRetry = track.status == TrackStatus.DownloadFailed + || track.status == TrackStatus.FileDeleted + holder.b.retryDownloadContainer.visibility = if (canRetry) View.VISIBLE else View.GONE + holder.b.retryDownloadContainer.setOnClickListener { reDownloadListener.onClick(track) } + + // hide on- cover views if retry is shown + holder.b.statusIcon.visibility = if (canRetry) View.GONE else View.VISIBLE + holder.b.duration.visibility = if (canRetry) View.GONE else View.VISIBLE + + // duration + if (track.duration != null) { + holder.b.duration.text = track.duration?.secondsToTimeString() + holder.b.duration.visibility = View.VISIBLE + } else { + holder.b.duration.visibility = View.GONE + } + + // set click listener + holder.b.root.setOnClickListener { clickListener.onClick(track) } + + + // deleted mode: + // setup delete listener + holder.b.undo.setOnClickListener { + itemsToDelete[position] = false + notifyItemChanged(position) + } + + // make view go into delete mode + val isToDelete = itemsToDelete[position] + holder.setDeletedMode(isToDelete != null && isToDelete) + } + + /** + * show a undo button for a while, then remove the item + * + * @param item the item to remove + * @param deleteCallback callback to actually delete the item. called on main thread + */ + fun deleteLater(item: Holder, deleteCallback: ItemListener) { + val position = item.bindingAdapterPosition + val track = tracks[position] + + // mark as to delete + itemsToDelete[position] = true + + // delete after a delay + launchMain { + delay(5000) + if (itemsToDelete[position] == false) + return@launchMain + + // remove from map + itemsToDelete.remove(position) + + // animate removal nicely + notifyItemRemoved(position) + + // actually remove after short delay + delay(100) + deleteCallback.onClick(track) + } + + // update to reflect new deleted state + notifyItemChanged(position) + } + + override fun getItemCount(): Int { + return tracks.size + } + + /** + * a view holder for the items of this adapter + */ + class Holder( + /** + * view binding of the view this holder holds + */ + val b: RecyclerTrackViewBinding + ) : RecyclerView.ViewHolder(b.root) { + + /** + * set if the 'deleted mode' view should be used + * + * @param deletedMode is this item in deleted mode? + */ + fun setDeletedMode(deletedMode: Boolean) { + b.containerMain.visibility = if (deletedMode) View.INVISIBLE else View.VISIBLE + b.containerUndo.visibility = if (deletedMode) View.VISIBLE else View.GONE + } + + } + + /** + * a click listener for track items + */ + fun interface ItemListener { + /** + * called when a track view is selected + * + * @param track the track the view shows + */ + fun onClick(track: TrackInfo) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksFragment.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksFragment.kt new file mode 100644 index 0000000..ff9809f --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksFragment.kt @@ -0,0 +1,151 @@ +package io.github.shadow578.yodel.ui.tracks + +import android.content.Intent +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.github.shadow578.music_dl.R +import io.github.shadow578.music_dl.databinding.FragmentTracksBinding +import io.github.shadow578.yodel.db.TracksDB +import io.github.shadow578.yodel.db.model.TrackInfo +import io.github.shadow578.yodel.db.model.TrackStatus +import io.github.shadow578.yodel.ui.base.BaseFragment +import io.github.shadow578.yodel.util.SwipeToDeleteCallback +import io.github.shadow578.yodel.util.launchIO +import io.github.shadow578.yodel.util.storage.decodeToUri + +/** + * downloaded and downloading tracks UI + */ +class TracksFragment() : BaseFragment() { + /** + * the view binding instance + */ + private lateinit var model: TracksViewModel + + /** + * the view model instance + */ + private lateinit var b: FragmentTracksBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + b = FragmentTracksBinding.inflate(inflater, container, false) + return b.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + model = ViewModelProvider(this).get(TracksViewModel::class.java) + + // setup recycler with data from model + val tracksAdapter = TracksAdapter(requireActivity(), model.tracks, + { track: TrackInfo -> + playTrack( + track + ) + }, + { track: TrackInfo -> + reDownloadTrack( + track + ) + }) + b.tracksRecycler.layoutManager = LinearLayoutManager(requireContext()) + + // show empty label if no tracks available + model.tracks.observe(requireActivity(), + { tracks: List -> + b.emptyLabel.visibility = if (tracks.isNotEmpty()) View.GONE else View.VISIBLE + }) + + // setup swipe to delete + val swipeToDelete = ItemTouchHelper(object : SwipeToDeleteCallback( + requireContext(), + resolveColor(R.attr.colorError), + R.drawable.ic_round_close_24, + resolveColor(R.attr.colorOnError), + 15 + ) { + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + if (viewHolder !is TracksAdapter.Holder) { + throw IllegalStateException("got a wrong typed view holder") + } + tracksAdapter.deleteLater( + viewHolder + ) { track: TrackInfo -> + + launchIO { + TracksDB.get(this@TracksFragment.requireContext()).tracks() + .remove(track) + } + } + } + }) + swipeToDelete.attachToRecyclerView(b.tracksRecycler) + b.tracksRecycler.adapter = tracksAdapter + } + + /** + * play a track + * + * @param track the track to play + */ + private fun playTrack(track: TrackInfo) { + // decode track audio file key + val trackUri = track.audioFileKey.decodeToUri() + if (trackUri != null) { + Toast.makeText(requireContext(), R.string.tracks_play_failed, Toast.LENGTH_SHORT).show() + return + } + + // start external player + val playIntent = Intent(Intent.ACTION_VIEW) + .setDataAndType(trackUri, "audio/*") + .addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + or Intent.FLAG_ACTIVITY_SINGLE_TOP + or Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + startActivity(playIntent) + } + + /** + * re- download a track + * + * @param track the track to re- download + */ + private fun reDownloadTrack(track: TrackInfo) { + // reset status to pending + track.status = TrackStatus.DownloadPending + + // overwrite entry in db + launchIO { + TracksDB.get(requireContext()).tracks().insert(track) + } + } + + /** + * resolve a color from a attribute + * + * @param attr the color attribute to resolve + * @return the resolved color int + */ + @ColorInt + private fun resolveColor(@AttrRes attr: Int): Int { + val value = TypedValue() + requireContext().theme.resolveAttribute(attr, value, true) + return value.data + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksViewModel.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksViewModel.kt new file mode 100644 index 0000000..b45616f --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksViewModel.kt @@ -0,0 +1,15 @@ +package io.github.shadow578.yodel.ui.tracks + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import io.github.shadow578.yodel.db.TracksDB +import io.github.shadow578.yodel.db.model.TrackInfo + +/** + * view model for tracks + */ +class TracksViewModel(application: Application) : AndroidViewModel(application) { + val tracks: LiveData> + get() = TracksDB.get(getApplication()).tracks().observe() +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/util/Lang.kt b/app/src/main/kotlin/io/github/shadow578/yodel/util/Lang.kt new file mode 100644 index 0000000..3f6a2c6 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/util/Lang.kt @@ -0,0 +1,28 @@ +package io.github.shadow578.yodel.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.* + +/** + * run a IO coroutine + * + * @param block coroutine block to run + */ +fun launchIO(block: suspend CoroutineScope.() -> Unit) = + CoroutineScope(Dispatchers.IO).launch(block = block) + + +/** + * run a coroutine in the main / UI thread + * + * @param block coroutine block to run + */ +fun launchMain(block: suspend CoroutineScope.() -> Unit) = + CoroutineScope(Dispatchers.Main).launch(block = block) + +/** + * bride for [Optional] to kotlin nullables + */ +fun Optional.unwrap(): T? = orElse(null) diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/util/NotificationChannels.kt b/app/src/main/kotlin/io/github/shadow578/yodel/util/NotificationChannels.kt new file mode 100644 index 0000000..7a1af3d --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/util/NotificationChannels.kt @@ -0,0 +1,80 @@ +package io.github.shadow578.yodel.util + +import android.content.Context +import androidx.annotation.StringRes +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationManagerCompat +import io.github.shadow578.music_dl.R + +/** + * class to handle notification channels + * + * @param displayName display name resource + * @param description description text resource + * @param importance importance of this channel + */ +enum class NotificationChannels( + @StringRes private val displayName: Int? = null, + @StringRes private val description: Int? = null, + @StringRes private val importance: Int = NotificationManagerCompat.IMPORTANCE_DEFAULT +) { + /** + * default notification channel. + *

+ * only for use when testing stuff (and the actual channel is not setup yet) or for notifications that are normally not shown + */ + Default( + R.string.channel_default_name, + R.string.channel_default_description + ), + + /** + * notification channel used by {@link io.github.shadow578.music_dl.downloader.DownloaderService} to show download progress + */ + DownloadProgress( + R.string.channel_downloader_name, + R.string.channel_downloader_description, + NotificationManagerCompat.IMPORTANCE_LOW + ); + + + // region boring background stuff + /** + * the id of this channel definition + */ + val id: String + get() = "io.github.shadow578.youtube_dl.${name.uppercase()}" + + /** + * create the notification channel from the definition + * + * @param ctx the context to resolve strings in + * @return the channel, with id, name, desc and importance set + */ + private fun createChannel(ctx: Context): NotificationChannelCompat { + return NotificationChannelCompat.Builder(id, importance).apply { + // set name with fallback + setName(if (displayName != null) ctx.getString(displayName) else id) + + // set description + setDescription(if (description != null) ctx.getString(description) else null) + }.build() + } + + companion object { + /** + * register all notification channels + * + * @param ctx the context to register in + */ + fun registerAll(ctx: Context) { + // get notification manager + val notificationManager = NotificationManagerCompat.from(ctx) + + // register channels + for (ch in values()) + notificationManager.createNotificationChannel(ch.createChannel(ctx)) + } + } + //endregion +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/util/SwipeToDeleteCallback.kt b/app/src/main/kotlin/io/github/shadow578/yodel/util/SwipeToDeleteCallback.kt new file mode 100644 index 0000000..b51a863 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/util/SwipeToDeleteCallback.kt @@ -0,0 +1,111 @@ +package io.github.shadow578.yodel.util + +import android.content.Context +import android.graphics.Canvas +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.util.TypedValue +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.BlendModeColorFilterCompat +import androidx.core.graphics.BlendModeCompat +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView + +/** + * swipe to delete handler for [ItemTouchHelper] + * + * @param ctx context to work in + * @param backgroundColor background drawable + * @param iconRes icon to draw + * @param iconTint icon tint color + * @param iconMarginDp margins of the icon, in dp + */ +abstract class SwipeToDeleteCallback( + ctx: Context, + @ColorInt backgroundColor: Int, + @DrawableRes iconRes: Int, + @ColorInt iconTint: Int, + iconMarginDp: Int +) : + ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) { + /** + * background drawable + */ + private val background: Drawable + + /** + * icon to draw + */ + private val icon: Drawable + + /** + * margins of the icon + */ + private val iconMargin: Int + + init { + // create background color drawable + background = ColorDrawable(backgroundColor) + + // load icon drawable + icon = ContextCompat.getDrawable(ctx, iconRes)!! + icon.colorFilter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat( + iconTint, + BlendModeCompat.SRC_ATOP + ) + + // get margin + iconMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + iconMarginDp.toFloat(), + ctx.resources.displayMetrics + ).toInt() + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + // don't care + return false + } + + override fun onChildDraw( + c: Canvas, + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + dX: Float, + dY: Float, + actionState: Int, + isCurrentlyActive: Boolean + ) { + // skip for views outside of view + if (viewHolder.absoluteAdapterPosition < 0) return + val view = viewHolder.itemView + + // draw the red background + background.setBounds( + (view.right + dX).toInt(), + view.top, + view.right, + view.bottom + ) + background.draw(c) + + // calculate icon bounds + val iconLeft = view.right - iconMargin - icon.intrinsicWidth + val iconRight = view.right - iconMargin + val iconTop = view.top + (view.height - icon.intrinsicHeight) / 2 + val iconBottom = iconTop + icon.intrinsicHeight + + // draw icon + icon.setBounds(iconLeft, iconTop, iconRight, iconBottom) + icon.draw(c) + + // do normal stuff, idk + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) + } +} diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/util/Util.kt b/app/src/main/kotlin/io/github/shadow578/yodel/util/Util.kt new file mode 100644 index 0000000..73f99a7 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/util/Util.kt @@ -0,0 +1,121 @@ +package io.github.shadow578.yodel.util + +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Configuration +import android.os.Build +import android.os.LocaleList +import io.github.shadow578.music_dl.LocaleOverride +import io.github.shadow578.music_dl.util.preferences.Prefs +import java.io.File +import java.util.regex.Pattern + +// region Youtube Util +/** + * youtube full link ID regex. + * CG1 = ID + */ +private val FULL_LINK_PATTERN = + Pattern.compile("""(?:https?://)?(?:music.)?(?:youtube.com)(?:/.*watch?\?)(?:.*)?(?:v=)([^&]+)(?:&)?(?:.*)?""") + +/** + * youtube short link ID regex. + * CG1 = ID + */ +private val SHORT_LINK_PATTERN = + Pattern.compile("""(?:https?://)?(?:youtu.be/)([^&]+)(?:&)?(?:.*)?""") + +/** + * extract the track ID from a youtube (music) url (like (music.)youtube.com/watch?v=xxxxx) + * + * @param url the url to extract the id from + * @return the id + */ +fun extractTrackId(url: String): String? { + // first try full link + var m = FULL_LINK_PATTERN.matcher(url) + if (m.find()) + return m.group(1) + + // try short link + m = SHORT_LINK_PATTERN.matcher(url) + return if (m.find()) m.group(1) else null +} +//endregion + +//region file / IO util +/** + * generate a random alphanumeric string with length characters + * + * @param length the length of the string to generate + * @return the random string + */ +fun generateRandomAlphaNumeric(length: Int): String { + val chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + val sb = StringBuilder() + repeat(length) { sb.append(chars.random()) } + return sb.toString() +} + +/** + * get a randomly named file with this directory as the parent directory. the file will not exist + * + * @param prefix the prefix to the file name + * @param suffix the suffix to the file name + * @return the file, with randomized filename. the file is **not** created by this function + */ +fun File.getTempFile(prefix: String, suffix: String): File { + var tempFile: File + do { + tempFile = File(this, prefix + generateRandomAlphaNumeric(32) + suffix) + } while (tempFile.exists()) + return tempFile +} +//endregion + +/** + * format a seconds value to HH:mm:ss or mm:ss format + * + * @return the formatted string + */ +fun Long.secondsToTimeString(): String { + val hours = this / 3600 + return if (hours <= 0) { + // less than 1h, use mm:ss + "%01d:%02d".format( + this % 3600 / 60, + this % 60 + ) + } else { + // more than 1h, use HH:mm:ss + "%01d:%02d:%02d".format( + hours, + this % 3600 / 60, + this % 60 + ) + } +} + +/** + * wrap the config to use the target locale from [Prefs.LocaleOverride] + * + * @return the (maybe) wrapped context with the target locale + */ +fun Context.wrapLocale(): Context { + // get preference setting + val localeOverride = Prefs.LocaleOverride.get() + + // do no overrides when using system default + if (localeOverride == LocaleOverride.SystemDefault) + return this + + // create configuration with that locale + val config = Configuration(this.resources.configuration) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + config.setLocales(LocaleList(localeOverride.locale())) + else + config.setLocale(localeOverride.locale()) + + // wrap the context + return ContextWrapper(this.createConfigurationContext(config)) +} diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/util/preferences/PreferenceWrapper.kt b/app/src/main/kotlin/io/github/shadow578/yodel/util/preferences/PreferenceWrapper.kt new file mode 100644 index 0000000..dc85fa8 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/util/preferences/PreferenceWrapper.kt @@ -0,0 +1,91 @@ +package io.github.shadow578.yodel.util.preferences + +import android.content.SharedPreferences +import com.google.gson.Gson + +/** + * wrapper class for shared preferences. init before first use using [.init] + * + * @param the type of this preference + * @param type the type of this preference + * @param key preference key + * @param defaultValue default value to use in [get] + */ +//TODO use KClass instead of java Class +class PreferenceWrapper private constructor( + private val key: String, + private val type: Class, + private val defaultValue: T +) { + companion object { + /** + * internal gson reference. all values are internally saved as json + */ + private val gson = Gson() + + /** + * the shared preferences to store values in + */ + private lateinit var prefs: SharedPreferences + + /** + * initialize all preference wrappers. you'll have to call this before using any preference + * + * @param prefs the shared preferences to wrap + */ + fun init(prefs: SharedPreferences) { + Companion.prefs = prefs + } + + /** + * create a new preference wrapper + * + * @param type the type of the preference + * @param key the key of the preference + * @param defaultValue the default value of the preference + * @param type of the preference + * @return the preference wrapper + */ + fun create(type: Class, key: String, defaultValue: T): PreferenceWrapper = + PreferenceWrapper(key, type, defaultValue) + } + + /** + * get the value. if the preference is not set, uses the provided value + * + * @return the preference value + */ + fun get(fallback: T = defaultValue): T { + val json = prefs.getString(key, null) + val value = if (json.isNullOrBlank()) fallback else gson.fromJson(json, type) + return value ?: fallback + } + + /** + * set the value of this preference + * + * @param value the value to set. if null, the value is reset to default + */ + fun set(value: T?) { + // reset if value is null + if (value == null) { + reset() + return + } + + // write value as json + val json = gson.toJson(value) + prefs.edit() + .putString(key, json) + .apply() + } + + /** + * remove this preference from the values set + */ + fun reset() { + prefs.edit() + .remove(key) + .apply() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/util/preferences/Prefs.kt b/app/src/main/kotlin/io/github/shadow578/yodel/util/preferences/Prefs.kt new file mode 100644 index 0000000..ae41d35 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/util/preferences/Prefs.kt @@ -0,0 +1,55 @@ +package io.github.shadow578.yodel.util.preferences + +import io.github.shadow578.yodel.downloader.TrackDownloadFormat +import io.github.shadow578.yodel.LocaleOverride +import io.github.shadow578.yodel.util.storage.StorageKey + +/** + * app preferences storage + */ +object Prefs { + /** + * the main downloads directory file key + */ + val DownloadsDirectory = PreferenceWrapper.create( + StorageKey::class.java, + "downloads_dir", + StorageKey.EMPTY + ) + + /** + * enable [YoutubeDLWrapper.fixSsl] on track downloads + */ + val EnableSSLFix = PreferenceWrapper.create( + Boolean::class.java, + "enable_ssl_fix", + false + ) + + /** + * download format [YoutubeDLWrapper] should use for future downloads. existing downloads are not affected + */ + val DownloadFormat = PreferenceWrapper.create( + TrackDownloadFormat::class.java, + "track_download_format", + TrackDownloadFormat.MP3 + ) + + /** + * enable writing ID3 metadata on downloaded tracks (if format supports it) + */ + val EnableMetadataTagging = PreferenceWrapper.create( + Boolean::class.java, + "enable_meta_tagging", + true + ) + + /** + * override for the app locale + */ + val AppLocaleOverride = PreferenceWrapper.create( + LocaleOverride::class.java, + "locale_override", + LocaleOverride.SystemDefault + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/util/storage/StorageHelper.kt b/app/src/main/kotlin/io/github/shadow578/yodel/util/storage/StorageHelper.kt new file mode 100644 index 0000000..979481c --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/util/storage/StorageHelper.kt @@ -0,0 +1,137 @@ +package io.github.shadow578.yodel.util.storage + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Base64 +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import io.github.shadow578.yodel.util.unwrap +import java.nio.charset.StandardCharsets +import java.util.* + +// region URI encode / decode +/** + * encode a uri to a key for storage in a database, preferences, ... + * the string can be converted back to a uri using [StorageKey.decodeToUri] + * + * @return the encoded uri key + */ +fun Uri.encodeToKey(): StorageKey { + // get file uri + var encodedUri = this.toString() + + // encode the uri + encodedUri = Uri.encode(encodedUri) + + // base- 64 encode to ensure android does not cry about leaked paths or something... + return StorageKey( + Base64.encodeToString( + encodedUri.toByteArray(StandardCharsets.UTF_8), + Base64.NO_WRAP or Base64.URL_SAFE + ) + ) +} + +/** + * decode a key to a uri + * this function will only decode uris encoded with [Uri.encodeToKey] + * + * @return the decoded uri + */ +fun StorageKey.decodeToUri(): Uri? { + return try { + // base- 64 decode + var uriString: String? = String( + Base64.decode(this.key, Base64.NO_WRAP or Base64.URL_SAFE), + StandardCharsets.UTF_8 + ) + + // decode uri + uriString = Uri.decode(uriString) + + // pare uri + if (uriString == null || uriString.isEmpty()) null else Uri.parse(uriString) + } catch (e: IllegalArgumentException) { + Log.e("StorageHelper", "failed to decode key ${this.key}", e) + null + } +} +//endregion + +//region DocumentFile encode / decode +/** + * encode a file to a key for storage in a database, preferences, ... + * the string can be converted back to a file using [StorageKey.decodeToFile] + * + * @return the encoded file key + */ +fun DocumentFile.encodeToKey(): StorageKey = this.uri.encodeToKey() + +/** + * decode a key to a file + * this function will only decode files encoded with [DocumentFile.encodeToKey] + * + * @param ctx the context to create the file in + * @return the decoded file + */ +fun StorageKey.decodeToFile(ctx: Context): DocumentFile? { + val uri = this.decodeToUri() + return if (uri == null) null else DocumentFile.fromSingleUri(ctx, uri) +} +//endregion + +// region storage framework wrapper +/** + * persist a file permission. see [android.content.ContentResolver.takePersistableUriPermission]. + * uses flags `Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION` + * + * @param ctx the context to persist the permission in + * @return the key for this file. can be read back using [.getPersistedFilePermission] + */ +fun DocumentFile.persistFilePermission(ctx: Context): StorageKey = + this.uri.persistFilePermission(ctx) + +/** + * persist a file permission. see [android.content.ContentResolver.takePersistableUriPermission]. + * uses flags `Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION` + * + * @param ctx the context to persist the permission in + * @param uri the uri to take permission of + * @return the key for this uri. can be read back using [.getPersistedFilePermission] + */ +fun Uri.persistFilePermission(ctx: Context): StorageKey { + ctx.contentResolver.takePersistableUriPermission( + this, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + return this.encodeToKey() +} + +/** + * find a persisted file with a given key + * + * @param ctx the context to check in + * @param mustExist must the file exist? + * @return the file found + */ +fun StorageKey.getPersistedFilePermission( + ctx: Context, + mustExist: Boolean +): DocumentFile? { + // decode storage key + val targetUri = this.decodeToUri() + + // find the first persisted permission that matches the search + return if (targetUri == null) null else ctx.contentResolver.persistedUriPermissions + .stream() + .filter { it.isWritePermission } + .filter { it.uri == targetUri } + .map { DocumentFile.fromTreeUri(ctx, it.uri) } + .filter { it != null && it.canRead() && it.canWrite() } + .filter { !mustExist || it!!.exists() } + .findFirst() + .unwrap() +} + +//endregion \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/util/storage/StorageKey.kt b/app/src/main/kotlin/io/github/shadow578/yodel/util/storage/StorageKey.kt new file mode 100644 index 0000000..3984735 --- /dev/null +++ b/app/src/main/kotlin/io/github/shadow578/yodel/util/storage/StorageKey.kt @@ -0,0 +1,20 @@ +package io.github.shadow578.yodel.util.storage + +/** + * a storage key, used by [StorageHelper] + */ +data class StorageKey( + val key: String +) { + + override fun toString(): String { + return key; + } + + companion object { + /** + * a empty storage key + */ + val EMPTY = StorageKey("") + } +} \ No newline at end of file From 95a22de70080a58b1ee7acf525a3606769026005 Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Wed, 4 Aug 2021 09:57:55 +0200 Subject: [PATCH 03/22] port tests to kotlin --- .../shadow578/yodel/util/UtilAndroidTest.kt | 30 +++ .../util/storage/StorageHelperAndroidTest.kt | 90 ++++++++ .../io/github/shadow578/yodel/util/Util.kt | 21 +- .../yodel/downloader/TrackMetadataTest.kt | 197 ++++++++++++++++++ .../wrapper/YoutubeDLWrapperTest.kt | 67 ++++++ .../github/shadow578/yodel/util/UtilTest.kt | 96 +++++++++ 6 files changed, 495 insertions(+), 6 deletions(-) create mode 100644 app/src/androidTest/kotlin/io/github/shadow578/yodel/util/UtilAndroidTest.kt create mode 100644 app/src/androidTest/kotlin/io/github/shadow578/yodel/util/storage/StorageHelperAndroidTest.kt create mode 100644 app/src/test/kotlin/io/github/shadow578/yodel/downloader/TrackMetadataTest.kt create mode 100644 app/src/test/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapperTest.kt create mode 100644 app/src/test/kotlin/io/github/shadow578/yodel/util/UtilTest.kt diff --git a/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/UtilAndroidTest.kt b/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/UtilAndroidTest.kt new file mode 100644 index 0000000..11b11fd --- /dev/null +++ b/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/UtilAndroidTest.kt @@ -0,0 +1,30 @@ +package io.github.shadow578.yodel.util + +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.bumptech.glide.util.Util +import org.hamcrest.MatcherAssert +import org.hamcrest.core.Is +import org.hamcrest.core.IsNull +import org.junit.Test +import java.io.File + +/** + * instrumented test for [Util] + */ +@SmallTest +class UtilAndroidTest { + /** + * [getTempFile] + */ + @Test + fun shouldGetTempFile() { + val temp: File = + InstrumentationRegistry.getInstrumentation().targetContext.cacheDir.getTempFile( + "foo", + "bar" + ) + MatcherAssert.assertThat(temp, IsNull.notNullValue()) + MatcherAssert.assertThat(temp.exists(), Is.`is`(false)) + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/storage/StorageHelperAndroidTest.kt b/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/storage/StorageHelperAndroidTest.kt new file mode 100644 index 0000000..86a9093 --- /dev/null +++ b/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/storage/StorageHelperAndroidTest.kt @@ -0,0 +1,90 @@ +package io.github.shadow578.yodel.util.storage + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.core.IsEqual.equalTo +import org.hamcrest.core.IsNot.not +import org.hamcrest.core.IsNull +import org.junit.Test +import java.io.File + +/** + * instrumented test for StorageHelper + */ +@SmallTest +class StorageHelperAndroidTest { + /** + * [decodeToFile] and [decodeToUri] + */ + @Test + fun shouldEncodeAndDecodeUri() { + val uri = Uri.fromFile( + File( + InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, + "test.bar" + ) + ) + + // encode + val key: StorageKey = uri.encodeToKey() + assertThat( + key, IsNull.notNullValue( + StorageKey::class.java + ) + ) + + // decode + assertThat( + key.decodeToUri(), + equalTo(uri) + ) + } + + /** + * [decodeToUri] with invalid key + */ + @Test + fun shouldNotDecodeUri() { + assertThat(StorageKey.EMPTY.decodeToUri(), equalTo(null)) + } + + /** + * [encodeToKey] and [decodeToFile] + */ + @Test + fun shouldEncodeAndDecodeFile() { + val ctx = InstrumentationRegistry.getInstrumentation().targetContext + val uri = Uri.fromFile(File(ctx.cacheDir, "test.bar")) + val file = DocumentFile.fromSingleUri(ctx, uri) + + // check test setup + assertThat(file, IsNull.notNullValue()) + + // encode + val key: StorageKey = file!!.encodeToKey() + assertThat( + key, IsNull.notNullValue( + StorageKey::class.java + ) + ) + + // decode + val decodedFile: DocumentFile? = key.decodeToFile(ctx) + assertThat(decodedFile, not(equalTo(null))) + assertThat(decodedFile!!.uri, equalTo(file.uri)) + } + + /** + * [decodeToFile] with invalid key + */ + @Test + fun shouldNotDecodeFile() { + val ctx = InstrumentationRegistry.getInstrumentation().targetContext + + //empty key + assertThat(StorageKey.EMPTY.decodeToFile(ctx), equalTo(null)) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/util/Util.kt b/app/src/main/kotlin/io/github/shadow578/yodel/util/Util.kt index 73f99a7..d139b29 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/util/Util.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/util/Util.kt @@ -5,8 +5,8 @@ import android.content.ContextWrapper import android.content.res.Configuration import android.os.Build import android.os.LocaleList -import io.github.shadow578.music_dl.LocaleOverride -import io.github.shadow578.music_dl.util.preferences.Prefs +import io.github.shadow578.yodel.LocaleOverride +import io.github.shadow578.yodel.util.preferences.Prefs import java.io.File import java.util.regex.Pattern @@ -73,6 +73,15 @@ fun File.getTempFile(prefix: String, suffix: String): File { } //endregion +/** + * format a seconds value to HH:mm:ss or mm:ss format + * + * @return the formatted string + */ +fun Int.secondsToTimeString(): String { + return this.toLong().secondsToTimeString() +} + /** * format a seconds value to HH:mm:ss or mm:ss format * @@ -97,13 +106,13 @@ fun Long.secondsToTimeString(): String { } /** - * wrap the config to use the target locale from [Prefs.LocaleOverride] + * wrap the config to use the target locale from [LocaleOverride] * * @return the (maybe) wrapped context with the target locale */ fun Context.wrapLocale(): Context { // get preference setting - val localeOverride = Prefs.LocaleOverride.get() + val localeOverride = Prefs.AppLocaleOverride.get() // do no overrides when using system default if (localeOverride == LocaleOverride.SystemDefault) @@ -112,9 +121,9 @@ fun Context.wrapLocale(): Context { // create configuration with that locale val config = Configuration(this.resources.configuration) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) - config.setLocales(LocaleList(localeOverride.locale())) + config.setLocales(LocaleList(localeOverride.locale)) else - config.setLocale(localeOverride.locale()) + config.setLocale(localeOverride.locale) // wrap the context return ContextWrapper(this.createConfigurationContext(config)) diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/downloader/TrackMetadataTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/downloader/TrackMetadataTest.kt new file mode 100644 index 0000000..6b0fde5 --- /dev/null +++ b/app/src/test/kotlin/io/github/shadow578/yodel/downloader/TrackMetadataTest.kt @@ -0,0 +1,197 @@ +package io.github.shadow578.yodel.downloader + +import com.google.gson.Gson +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.* +import org.junit.Before +import org.junit.Test +import java.time.LocalDate + +/** + * test for [TrackMetadata] + */ +class TrackMetadataTest { + private lateinit var metaFull: TrackMetadata + private lateinit var metaNoTrackArtistDate: TrackMetadata + private lateinit var metaNoAltTitleCreatorBadDate: TrackMetadata + private lateinit var metaNoTitleChannelDate: TrackMetadata + + + /** + * deserialize a mock metadata json object + */ + @Before + fun deserializeMetadataJson() { + // prepare json (totally not based on real data) + val jsonFull = """ +{ + "track": "The Commerce", + "tags": [ + "Otseit", + "The Commerce" + ], + "view_count": 128898492, + "average_rating": 4.888588, + "upload_date": "20200924", + "channel": "Otseit", + "duration": 192, + "creator": "Otseit", + "dislike_count": 34855, + "artist": "Otseit,AWatson", + "album": "The Commerce", + "title": "Otseit - The Commerce (Official Music Video)", + "alt_title": "Otseit - The Commerce", + "categories": [ + "Music" + ], + "like_count": 1216535 +} +""" + val jsonNoTrackArtistUpload = """ +{ + "track": "", + "tags": [ + "Otseit", + "The Commerce" + ], + "view_count": 128898492, + "average_rating": 4.888588, + "upload_date": "", + "channel": "Otseit", + "duration": 192, + "creator": "Otseit", + "dislike_count": 34855, + "artist": "", + "album": "The Commerce", + "title": "Otseit - The Commerce (Official Music Video)", + "alt_title": "Otseit - The Commerce", + "categories": [ + "Music" + ], + "like_count": 1216535 +} +""" + val jsonNoAltTitleCreatorBadDate = """ +{ + "track": "", + "tags": [ + "Otseit", + "The Commerce" + ], + "view_count": 128898492, + "average_rating": 4.888588, + "upload_date": "foobar", + "channel": "Otseit", + "duration": 192, + "creator": "", + "dislike_count": 34855, + "artist": "", + "album": "The Commerce", + "title": "Otseit - The Commerce (Official Music Video)", + "alt_title": "", + "categories": [ + "Music" + ], + "like_count": 1216535 +} +""" + val jsonNoTitleChannelDate = """ +{ + "track": "", + "tags": [ + "Otseit", + "The Commerce" + ], + "view_count": 128898492, + "average_rating": 4.888588, + "upload_date": "foobar", + "channel": "Otseit", + "duration": 192, + "creator": "", + "dislike_count": 34855, + "artist": "", + "album": "The Commerce", + "title": "Otseit - The Commerce (Official Music Video)", + "alt_title": "", + "categories": [ + "Music" + ], + "like_count": 1216535 +} +""" + + + // deserialize the object using (a default) GSON + val gson = Gson() + metaFull = gson.fromJson(jsonFull, TrackMetadata::class.java) + metaNoTrackArtistDate = gson.fromJson(jsonNoTrackArtistUpload, TrackMetadata::class.java) + metaNoAltTitleCreatorBadDate = + gson.fromJson(jsonNoAltTitleCreatorBadDate, TrackMetadata::class.java) + metaNoTitleChannelDate = gson.fromJson(jsonNoTitleChannelDate, TrackMetadata::class.java) + } + + /** + * testing if fields are deserialized correctly + */ + @Test + fun shouldDeserialize() { + // check fields are correct + assertThat(metaFull, notNullValue(TrackMetadata::class.java)) + assertThat(metaFull.track, equalTo("The Commerce")) + assertThat(metaFull.tags, containsInAnyOrder("Otseit", "The Commerce")) + assertThat(metaFull.view_count, equalTo(128898492L)) + assertThat(metaFull.average_rating, equalTo(4.888588)) + assertThat(metaFull.upload_date, equalTo("20200924")) + assertThat(metaFull.channel, equalTo("Otseit")) + assertThat(metaFull.duration, equalTo(192L)) + assertThat(metaFull.creator, equalTo("Otseit")) + assertThat(metaFull.dislike_count, equalTo(34855L)) + assertThat(metaFull.artist, equalTo("Otseit,AWatson")) + assertThat(metaFull.album, equalTo("The Commerce")) + assertThat( + metaFull.title, + equalTo("Otseit - The Commerce (Official Music Video)") + ) + assertThat(metaFull.alt_title, equalTo("Otseit - The Commerce")) + assertThat?>( + metaFull.categories, + containsInAnyOrder("Music") + ) + assertThat(metaFull.like_count, equalTo(1216535L)) + } + + /** + * [TrackMetadata.getTrackTitle] + */ + @Test + fun shouldGetTitle() { + assertThat(metaFull.getTrackTitle(), equalTo("The Commerce")) + assertThat(metaNoTrackArtistDate.getTrackTitle(), equalTo("Otseit - The Commerce")) + assertThat( + metaNoAltTitleCreatorBadDate.getTrackTitle(), + equalTo("Otseit - The Commerce (Official Music Video)") + ) + assertThat(metaNoTitleChannelDate.getTrackTitle(), nullValue()) + } + + /** + * [TrackMetadata.getArtistName] + */ + @Test + fun shouldGetArtistName() { + assertThat(metaFull.getArtistName(), equalTo("Otseit")) + assertThat(metaNoTrackArtistDate.getArtistName(), equalTo("Otseit")) + assertThat(metaNoAltTitleCreatorBadDate.getArtistName(), equalTo("Otseit")) + assertThat(metaNoTitleChannelDate.getArtistName(), nullValue()) + } + + /** + * [TrackMetadata.getUploadDate] + */ + @Test + fun shouldGetUploadDate() { + assertThat(metaFull.getUploadDate(), equalTo(LocalDate.of(2020, 9, 24))) + assertThat(metaNoTrackArtistDate.getUploadDate(), nullValue()) + assertThat(metaNoAltTitleCreatorBadDate.getUploadDate(), nullValue()) + } +} diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapperTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapperTest.kt new file mode 100644 index 0000000..71cea47 --- /dev/null +++ b/app/src/test/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapperTest.kt @@ -0,0 +1,67 @@ +package io.github.shadow578.yodel.downloader.wrapper + +import com.yausername.youtubedl_android.YoutubeDLRequest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.* +import org.junit.Test +import java.io.File + +/** + * test for [YoutubeDLWrapper] parameter creation + */ +class YoutubeDLWrapperTest { + /** + * test parameter list resulting from calls to wrapper function + */ + @Test + fun shouldBuildParameterList() { + // create session with some parameters + val videoUrl = "aaBBccDD" + val targetFile = File("/tmp/download/test.mp3") + val cacheDir = File("/tmp/download/cache/") + val session: YoutubeDLWrapper = YoutubeDLWrapper(videoUrl) + .overwriteExisting() + .fixSsl() + .audioAndVideo() + .writeMetadata() + .writeThumbnail() + .output(targetFile) + .cacheDir(cacheDir) + + // check internal request has correct parameters + val request: YoutubeDLRequest = session.request + assertThat(request, notNullValue(YoutubeDLRequest::class.java)) + var args = request.buildCommand() + assertThat(args, hasItem("--no-continue")) + assertThat( + args, + hasItems("--no-check-certificate", "--prefer-insecure") + ) + assertThat(args, hasItems("-f", "best")) + assertThat(args, hasItem("--write-info-json")) + assertThat(args, hasItem("--write-thumbnail")) + assertThat(args, hasItems("-o", targetFile.absolutePath)) + assertThat(args, hasItems("--cache-dir", cacheDir.absolutePath)) + assertThat(args, hasItem(videoUrl)) + + // audio only + session.audioOnly("mp3") + args = session.request.buildCommand() + assertThat(args, hasItems("-f", "bestaudio")) + assertThat(args, hasItems("--extract-audio")) + assertThat(args, hasItems("--audio-quality", "0")) + assertThat(args, hasItems("--audio-format", "mp3")) + + + // video only + session.videoOnly() + args = session.request.buildCommand() + assertThat(args, hasItems("-f", "bestvideo")) + + // custom option + session.setOption("--foo", "bar") + .setOption("--yee", null) + args = session.request.buildCommand() + assertThat(args, hasItems("--foo", "bar", "--yee")) + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilTest.kt new file mode 100644 index 0000000..bf6ec36 --- /dev/null +++ b/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilTest.kt @@ -0,0 +1,96 @@ +package io.github.shadow578.yodel.util + +import com.bumptech.glide.util.Util +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers +import org.hamcrest.core.IsEqual.equalTo +import org.hamcrest.text.IsEqualIgnoringCase.equalToIgnoringCase +import org.junit.Test + +/** + * test for [Util] + */ +class UtilTest { + /** + * [extractTrackId] + */ + @Test + fun shouldExtractVideoId() { + // youtube full + assertThat( + extractTrackId("https://www.youtube.com/watch?v=6Xs26b4RSu4"), + equalToIgnoringCase("6Xs26b4RSu4") + ) + + // youtube short (https) + assertThat( + extractTrackId("https://youtu.be/6Xs26b4RSu4"), + equalToIgnoringCase("6Xs26b4RSu4") + ) + + // youtube short (http) + assertThat( + extractTrackId("http://youtu.be/6Xs26b4RSu4"), + equalToIgnoringCase("6Xs26b4RSu4") + ) + + // youtube short (no protocol) + assertThat( + extractTrackId("youtu.be/6Xs26b4RSu4"), + equalToIgnoringCase("6Xs26b4RSu4") + ) + + // youtube full with playlist + assertThat( + extractTrackId("https://www.youtube.com/watch?v=6Xs26b4RSu4&list=RD6Xs26b4RSu4&start_radio=1&rv=6Xs26b4RSu4&t=0"), + equalToIgnoringCase("6Xs26b4RSu4") + ) + + + // youtube music + assertThat( + extractTrackId("https://music.youtube.com/watch?v=wbJwhx29O5U&list=RDAMVMwbJwhx29O5U"), + equalToIgnoringCase("wbJwhx29O5U") + ) + + // youtube music by share dialog + assertThat( + extractTrackId("https://music.youtube.com/watch?v=wbJwhx29O5U&feature=share"), + equalToIgnoringCase("wbJwhx29O5U") + ) + + // invalid link + assertThat(extractTrackId("foobar"), equalTo(null)) + } + + /** + * [Util.generateRandomAlphaNumeric] + */ + @Test + fun shouldGenerateRandomString() { + val random: String = generateRandomAlphaNumeric(128) + assertThat( + random, Matchers.notNullValue( + String::class.java + ) + ) + assertThat(random.length, Matchers.`is`(128)) + assertThat(random.isEmpty(), Matchers.`is`(false)) + } + + /** + * [secondsToTimeString] + */ + @Test + fun shouldConvertSecondsToString() { + // < 1h + assertThat(620.secondsToTimeString(), Matchers.equalTo("10:20")) + assertThat(520.secondsToTimeString(), Matchers.equalTo("8:40")) + + // > 1h + assertThat(7300.secondsToTimeString(), Matchers.equalTo("2:01:40")) + + // > 10d + assertThat(172800.secondsToTimeString(), Matchers.equalTo("48:00:00")) + } +} \ No newline at end of file From 1223a892de8a42a09035519b04e9efb5665f13a2 Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Wed, 4 Aug 2021 09:59:25 +0200 Subject: [PATCH 04/22] remove java files --- .../shadow578/music_dl/util/UtilTest.java | 27 - .../util/storage/StorageHelperTest.java | 83 --- .../github/shadow578/music_dl/KtPorted.java | 8 - .../shadow578/music_dl/LocaleOverride.java | 78 -- .../github/shadow578/music_dl/MusicDLApp.java | 30 - .../music_dl/MusicDLGlideModule.java | 12 - .../shadow578/music_dl/backup/BackupData.java | 33 - .../music_dl/backup/BackupGSONAdapters.java | 70 -- .../music_dl/backup/BackupHelper.java | 109 --- .../music_dl/db/DBTypeConverters.java | 81 --- .../shadow578/music_dl/db/TracksDB.java | 114 --- .../shadow578/music_dl/db/TracksDao.java | 118 --- .../music_dl/db/model/TrackInfo.java | 133 ---- .../music_dl/db/model/TrackStatus.java | 77 -- .../downloader/DownloaderException.java | 22 - .../downloader/DownloaderService.java | 670 ------------------ .../music_dl/downloader/TempFiles.java | 116 --- .../downloader/TrackDownloadFormat.java | 116 --- .../music_dl/downloader/TrackMetadata.java | 226 ------ .../downloader/wrapper/MP3agicWrapper.java | 139 ---- .../downloader/wrapper/YoutubeDLWrapper.java | 320 --------- .../share/DownloadBroadcastReceiver.java | 105 --- .../music_dl/share/ShareTargetActivity.java | 94 --- .../music_dl/ui/InsertTrackUIHelper.java | 81 --- .../music_dl/ui/base/BaseActivity.java | 99 --- .../music_dl/ui/base/BaseFragment.java | 15 - .../music_dl/ui/main/MainActivity.java | 177 ----- .../music_dl/ui/main/MainViewModel.java | 59 -- .../music_dl/ui/more/MoreFragment.java | 200 ------ .../music_dl/ui/more/MoreViewModel.java | 226 ------ .../ui/splash/SplashScreenActivity.java | 28 - .../music_dl/ui/tracks/TracksAdapter.java | 241 ------- .../music_dl/ui/tracks/TracksFragment.java | 140 ---- .../music_dl/ui/tracks/TracksViewModel.java | 26 - .../github/shadow578/music_dl/util/Async.java | 56 -- .../shadow578/music_dl/util/LocaleUtil.java | 47 -- .../music_dl/util/SwipeToDeleteCallback.java | 106 --- .../github/shadow578/music_dl/util/Util.java | 145 ---- .../notifications/NotificationChannels.java | 148 ---- .../util/preferences/PreferenceWrapper.java | 154 ---- .../music_dl/util/preferences/Prefs.java | 39 - .../music_dl/util/storage/StorageHelper.java | 164 ----- .../music_dl/util/storage/StorageKey.java | 42 -- .../downloader/TrackMetadataTest.java | 131 ---- .../wrapper/YoutubeDLWrapperTest.java | 73 -- .../shadow578/music_dl/util/UtilTest.java | 100 --- 46 files changed, 5278 deletions(-) delete mode 100644 app/src/androidTest/java/io/github/shadow578/music_dl/util/UtilTest.java delete mode 100644 app/src/androidTest/java/io/github/shadow578/music_dl/util/storage/StorageHelperTest.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/KtPorted.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/LocaleOverride.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/MusicDLApp.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/MusicDLGlideModule.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/backup/BackupData.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/backup/BackupGSONAdapters.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/backup/BackupHelper.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/db/DBTypeConverters.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/db/TracksDB.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/db/TracksDao.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/db/model/TrackInfo.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/db/model/TrackStatus.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/downloader/DownloaderException.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/downloader/DownloaderService.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/downloader/TempFiles.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/downloader/TrackDownloadFormat.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/downloader/TrackMetadata.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/downloader/wrapper/MP3agicWrapper.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/downloader/wrapper/YoutubeDLWrapper.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/share/DownloadBroadcastReceiver.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/share/ShareTargetActivity.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/ui/InsertTrackUIHelper.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/ui/base/BaseActivity.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/ui/base/BaseFragment.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/ui/main/MainActivity.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/ui/main/MainViewModel.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/ui/more/MoreFragment.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/ui/more/MoreViewModel.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/ui/splash/SplashScreenActivity.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksAdapter.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksFragment.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksViewModel.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/util/Async.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/util/LocaleUtil.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/util/SwipeToDeleteCallback.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/util/Util.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/util/notifications/NotificationChannels.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/util/preferences/PreferenceWrapper.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/util/preferences/Prefs.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/util/storage/StorageHelper.java delete mode 100644 app/src/main/java/io/github/shadow578/music_dl/util/storage/StorageKey.java delete mode 100644 app/src/test/java/io/github/shadow578/music_dl/downloader/TrackMetadataTest.java delete mode 100644 app/src/test/java/io/github/shadow578/music_dl/downloader/wrapper/YoutubeDLWrapperTest.java delete mode 100644 app/src/test/java/io/github/shadow578/music_dl/util/UtilTest.java diff --git a/app/src/androidTest/java/io/github/shadow578/music_dl/util/UtilTest.java b/app/src/androidTest/java/io/github/shadow578/music_dl/util/UtilTest.java deleted file mode 100644 index d0f2283..0000000 --- a/app/src/androidTest/java/io/github/shadow578/music_dl/util/UtilTest.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.github.shadow578.music_dl.util; - -import androidx.test.platform.app.InstrumentationRegistry; - -import org.junit.Test; - -import java.io.File; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.core.Is.is; -import static org.hamcrest.core.IsNull.notNullValue; - -/** - * instrumented test for {@link Util} - */ -public class UtilTest { - - /** - * {@link Util#getTempFile(String, String, File)} - */ - @Test - public void shouldGetTempFile() { - final File temp = Util.getTempFile("foo", "bar", InstrumentationRegistry.getInstrumentation().getTargetContext().getCacheDir()); - assertThat(temp, notNullValue()); - assertThat(temp.exists(), is(false)); - } -} diff --git a/app/src/androidTest/java/io/github/shadow578/music_dl/util/storage/StorageHelperTest.java b/app/src/androidTest/java/io/github/shadow578/music_dl/util/storage/StorageHelperTest.java deleted file mode 100644 index 45d6175..0000000 --- a/app/src/androidTest/java/io/github/shadow578/music_dl/util/storage/StorageHelperTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package io.github.shadow578.music_dl.util.storage; - -import android.content.Context; -import android.net.Uri; - -import androidx.documentfile.provider.DocumentFile; -import androidx.test.filters.SmallTest; -import androidx.test.platform.app.InstrumentationRegistry; - -import org.junit.Test; - -import java.io.File; -import java.util.Optional; - -import static com.github.npathai.hamcrestopt.OptionalMatchers.isEmpty; -import static com.github.npathai.hamcrestopt.OptionalMatchers.isPresentAnd; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.core.Is.is; -import static org.hamcrest.core.IsEqual.equalTo; -import static org.hamcrest.core.IsNull.notNullValue; - -/** - * instrumented test for {@link StorageHelper} - */ -@SmallTest -public class StorageHelperTest { - - /** - * {@link StorageHelper#encodeUri(Uri)} and {@link StorageHelper#decodeUri(StorageKey)} - */ - @Test - public void shouldEncodeAndDecodeUri() { - final Uri uri = Uri.fromFile(new File(InstrumentationRegistry.getInstrumentation().getTargetContext().getCacheDir(), "test.bar")); - - // encode - final StorageKey key = StorageHelper.encodeUri(uri); - assertThat(key, notNullValue(StorageKey.class)); - - // decode - assertThat(StorageHelper.decodeUri(key), isPresentAnd(equalTo(uri))); - } - - /** - * {@link StorageHelper#decodeUri(StorageKey)} with invalid key - */ - @Test - public void shouldNotDecodeUri() { - assertThat(StorageHelper.decodeUri(StorageKey.EMPTY), isEmpty()); - } - - /** - * {@link StorageHelper#encodeFile(DocumentFile)} and {@link StorageHelper#decodeFile(Context, StorageKey)} - */ - @Test - public void shouldEncodeAndDecodeFile() { - final Context ctx = InstrumentationRegistry.getInstrumentation().getTargetContext(); - final Uri uri = Uri.fromFile(new File(ctx.getCacheDir(), "test.bar")); - final DocumentFile file = DocumentFile.fromSingleUri(ctx, uri); - - // check test setup - assertThat(file, notNullValue()); - - // encode - final StorageKey key = StorageHelper.encodeFile(file); - assertThat(key, notNullValue(StorageKey.class)); - - // decode - final Optional decodedFile = StorageHelper.decodeFile(ctx, key); - assertThat(decodedFile.isPresent(), is(true)); - assertThat(decodedFile.get().getUri(), equalTo(file.getUri())); - } - - /** - * {@link StorageHelper#decodeFile(Context, StorageKey)} with invalid key - */ - @Test - public void shouldNotDecodeFile() { - final Context ctx = InstrumentationRegistry.getInstrumentation().getTargetContext(); - - //empty key - assertThat(StorageHelper.decodeFile(ctx, StorageKey.EMPTY), isEmpty()); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/KtPorted.java b/app/src/main/java/io/github/shadow578/music_dl/KtPorted.java deleted file mode 100644 index 0bc8efc..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/KtPorted.java +++ /dev/null @@ -1,8 +0,0 @@ -package io.github.shadow578.music_dl; - -/** - * helper annotation to mark a class as ported to kotlin - */ -@Deprecated -public @interface KtPorted { -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/LocaleOverride.java b/app/src/main/java/io/github/shadow578/music_dl/LocaleOverride.java deleted file mode 100644 index da9345b..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/LocaleOverride.java +++ /dev/null @@ -1,78 +0,0 @@ -package io.github.shadow578.music_dl; - - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.Locale; - -/** - * locale overrides - */ -@KtPorted -public enum LocaleOverride { - - SystemDefault, - - /** - * english locale - */ - English(new Locale("en", "")), - - /** - * german (germany) locale - */ - German(new Locale("de", "DE")); - - /** - * the internal locale. if null use system default - */ - @Nullable - private final Locale locale; - - /** - * extra: use system default locale - */ - LocaleOverride() { - this.locale = null; - } - - /** - * create a new locale override entry - * - * @param locale the locale to use in this override - */ - LocaleOverride(@NonNull Locale locale) { - this.locale = locale; - } - - /** - * @return locale for this override. invalid for {@link LocaleOverride#SystemDefault} - */ - @NonNull - public Locale locale() { - if (locale == null) { - throw new IllegalArgumentException("SystemDefault does not have a locale!"); - } - - return locale; - } - - /** - * @param ctx the context to work in - * @return the display name of the locale override - */ - @NonNull - public String displayName(@NonNull Context ctx) { - // check if this is FollowSystem - if (this.equals(SystemDefault)) { - return ctx.getString(R.string.locale_system_default); - } - - // not SystemDefault, get locale name - final Locale locale = locale(); - return locale.getDisplayName(locale); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/MusicDLApp.java b/app/src/main/java/io/github/shadow578/music_dl/MusicDLApp.java deleted file mode 100644 index d79dc9e..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/MusicDLApp.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.github.shadow578.music_dl; - -import android.app.Application; -import android.preference.PreferenceManager; -import android.util.Log; - -import io.github.shadow578.music_dl.db.TracksDB; -import io.github.shadow578.music_dl.util.Async; -import io.github.shadow578.music_dl.util.notifications.NotificationChannels; -import io.github.shadow578.music_dl.util.preferences.PreferenceWrapper; - -/** - * application instance, for boilerplate init - */ -@KtPorted -public class MusicDLApp extends Application { - - @Override - public void onCreate() { - super.onCreate(); - PreferenceWrapper.init(PreferenceManager.getDefaultSharedPreferences(this)); - NotificationChannels.registerAll(this); - - // remove tracks that were deleted - Async.runAsync(() -> { - final int removedCount = TracksDB.init(this).markDeletedTracks(this); - Log.i("MusicDL", String.format("removed %d tracks that were deleted in the file system", removedCount)); - }); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/MusicDLGlideModule.java b/app/src/main/java/io/github/shadow578/music_dl/MusicDLGlideModule.java deleted file mode 100644 index 12d9b84..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/MusicDLGlideModule.java +++ /dev/null @@ -1,12 +0,0 @@ -package io.github.shadow578.music_dl; - -import com.bumptech.glide.annotation.GlideModule; -import com.bumptech.glide.module.AppGlideModule; - -/** - * glide module. ikd what it does, but glide says we need it, so here it is - */ -@GlideModule -@KtPorted -public class MusicDLGlideModule extends AppGlideModule { -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/backup/BackupData.java b/app/src/main/java/io/github/shadow578/music_dl/backup/BackupData.java deleted file mode 100644 index 66a5ed1..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/backup/BackupData.java +++ /dev/null @@ -1,33 +0,0 @@ -package io.github.shadow578.music_dl.backup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.time.LocalDateTime; -import java.util.List; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.db.model.TrackInfo; - -/** - * backup data written by {@link BackupHelper} - */ -@KtPorted -public class BackupData { - /** - * the tracks in this backup - */ - @NonNull - public final List tracks; - - /** - * the time the backup was created - */ - @Nullable - public final LocalDateTime backupTime; - - public BackupData(@NonNull List tracks, @Nullable LocalDateTime backupTime) { - this.tracks = tracks; - this.backupTime = backupTime; - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/backup/BackupGSONAdapters.java b/app/src/main/java/io/github/shadow578/music_dl/backup/BackupGSONAdapters.java deleted file mode 100644 index bd0c879..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/backup/BackupGSONAdapters.java +++ /dev/null @@ -1,70 +0,0 @@ -package io.github.shadow578.music_dl.backup; - -import com.google.gson.TypeAdapter; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonToken; -import com.google.gson.stream.JsonWriter; - -import java.io.IOException; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; - -import io.github.shadow578.music_dl.KtPorted; - -/** - * gson adapters for backup data - */ -@KtPorted -public class BackupGSONAdapters { - - @KtPorted - public static class LocalDateTimeAdapter extends TypeAdapter { - - private static final DateTimeFormatter FORMAT = DateTimeFormatter.ISO_DATE_TIME; - - @Override - public void write(JsonWriter out, LocalDateTime value) throws IOException { - if (value == null) { - out.nullValue(); - } else { - out.value(FORMAT.format(value)); - } - } - - @Override - public LocalDateTime read(JsonReader in) throws IOException { - if (in.peek().equals(JsonToken.NULL)) { - in.nextNull(); - return null; - } else { - return LocalDateTime.parse(in.nextString(), FORMAT); - } - } - } - - @KtPorted - public static class LocalDateAdapter extends TypeAdapter { - - private static final DateTimeFormatter FORMAT = DateTimeFormatter.ISO_DATE; - - @Override - public void write(JsonWriter out, LocalDate value) throws IOException { - if (value == null) { - out.nullValue(); - } else { - out.value(FORMAT.format(value)); - } - } - - @Override - public LocalDate read(JsonReader in) throws IOException { - if (in.peek().equals(JsonToken.NULL)) { - in.nextNull(); - return null; - } else { - return LocalDate.parse(in.nextString(), FORMAT); - } - } - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/backup/BackupHelper.java b/app/src/main/java/io/github/shadow578/music_dl/backup/BackupHelper.java deleted file mode 100644 index 9568d4b..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/backup/BackupHelper.java +++ /dev/null @@ -1,109 +0,0 @@ -package io.github.shadow578.music_dl.backup; - -import android.content.Context; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.documentfile.provider.DocumentFile; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonIOException; -import com.google.gson.JsonSyntaxException; - -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.OutputStreamWriter; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.db.TracksDB; -import io.github.shadow578.music_dl.db.model.TrackInfo; - -/** - * tracks db backup helper class. - * all functions must be called from a background thread - */ -@KtPorted -public class BackupHelper { - /** - * gson for backup serialization and deserialization - */ - private static final Gson gson = new GsonBuilder() - .registerTypeAdapter(LocalDateTime.class, new BackupGSONAdapters.LocalDateTimeAdapter()) - .registerTypeAdapter(LocalDate.class, new BackupGSONAdapters.LocalDateAdapter()) - .create(); - - /** - * tag for logging - */ - private static final String TAG = "BackupHelper"; - - /** - * create a new backup of all tracks - * - * @param ctx the context to work in - * @param file the file to write the backup to - * @return was the backup successful - */ - public static boolean createBackup(@NonNull Context ctx, @NonNull DocumentFile file) { - // get all tracks in DB - final List tracks = TracksDB.init(ctx).tracks().getAll(); - if (tracks == null || tracks.size() <= 0) { - return false; - } - - // create backup data - final BackupData backup = new BackupData(tracks, LocalDateTime.now()); - - // serialize and write to file - try (final OutputStreamWriter out = new OutputStreamWriter(ctx.getContentResolver().openOutputStream(file.getUri()))) { - gson.toJson(backup, out); - return true; - } catch (IOException e) { - Log.e(TAG, "writing backup file failed!", e); - return false; - } - } - - /** - * read backup data from a file - * - * @param ctx the context to read in - * @param file the file to read the data from - * @return the backup data - */ - @NonNull - public static Optional readBackupData(@NonNull Context ctx, @NonNull DocumentFile file) { - try (final InputStreamReader src = new InputStreamReader(ctx.getContentResolver().openInputStream(file.getUri()))) { - return Optional.ofNullable(gson.fromJson(src, BackupData.class)); - } catch (IOException | JsonSyntaxException | JsonIOException e) { - Log.e(TAG, "failed to read backup data!", e); - return Optional.empty(); - } - } - - /** - * restore a backup into the db - * - * @param ctx the context to work in - * @param data the data to restore - * @param replaceExisting if true, existing entries are overwritten. if false, existing entries are not added - */ - public static void restoreBackup(@NonNull Context ctx, @NonNull BackupData data, boolean replaceExisting) { - // check there are tracks to import - if (data.tracks.size() <= 0) { - return; - } - - // insert the tracks - if (replaceExisting) { - TracksDB.init(ctx).tracks().insertAll(data.tracks); - } else { - TracksDB.init(ctx).tracks().insertAllNew(data.tracks); - } - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/db/DBTypeConverters.java b/app/src/main/java/io/github/shadow578/music_dl/db/DBTypeConverters.java deleted file mode 100644 index 7dad2f7..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/db/DBTypeConverters.java +++ /dev/null @@ -1,81 +0,0 @@ -package io.github.shadow578.music_dl.db; - -import androidx.room.TypeConverter; - -import java.time.LocalDate; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.db.model.TrackStatus; -import io.github.shadow578.music_dl.util.storage.StorageKey; - -/** - * type converters for room - */ -@KtPorted -class DBTypeConverters { - - //region StorageKey - @TypeConverter - public static String fromStorageKey(StorageKey key) { - if (key == null) { - return null; - } - - return key.toString(); - } - - @TypeConverter - public static StorageKey toStorageKey(String key) { - if (key == null) { - return null; - } - - return new StorageKey(key); - } - //endregion - - //region TrackStatus - @TypeConverter - public static String fromTrackStatus(TrackStatus status) { - if (status == null) { - return null; - } - - return status.key(); - } - - @TypeConverter - public static TrackStatus toTrackStatus(String key) { - if (key == null) { - return null; - } - - final TrackStatus s = TrackStatus.findByKey(key); - if (s != null) { - return s; - } - - return TrackStatus.DownloadPending; - } - //endregion - - //region LocalDate - @TypeConverter - public static String fromLocalDate(LocalDate date) { - if (date == null) { - return null; - } - - return date.toString(); - } - - @TypeConverter - public static LocalDate toLocalDate(String string) { - if (string == null) { - return null; - } - - return LocalDate.parse(string); - } - //endregion -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/db/TracksDB.java b/app/src/main/java/io/github/shadow578/music_dl/db/TracksDB.java deleted file mode 100644 index ab8e2af..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/db/TracksDB.java +++ /dev/null @@ -1,114 +0,0 @@ -package io.github.shadow578.music_dl.db; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.documentfile.provider.DocumentFile; -import androidx.room.Database; -import androidx.room.Room; -import androidx.room.RoomDatabase; -import androidx.room.TypeConverters; - -import java.io.File; -import java.util.List; -import java.util.Optional; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.db.model.TrackInfo; -import io.github.shadow578.music_dl.db.model.TrackStatus; -import io.github.shadow578.music_dl.util.storage.StorageHelper; - -/** - * the tracks database - */ -@KtPorted -@TypeConverters({ - DBTypeConverters.class -}) -@Database(entities = { - TrackInfo.class -}, version = 6) -public abstract class TracksDB extends RoomDatabase { - - /** - * database name - */ - private static final String DB_NAME = "tracks"; - - /** - * the instance singleton - */ - private static TracksDB INSTANCE; - - /** - * initialize the database - * - * @param ctx the context to work in - * @return the freshly initialized instance - */ - public static TracksDB init(@NonNull Context ctx) { - if (INSTANCE == null) { - INSTANCE = Room.databaseBuilder(ctx, TracksDB.class, DB_NAME) - .fallbackToDestructiveMigration() - .build(); - } - - return getInstance(); - } - - /** - * get the absolute path to the database file - * - * @param ctx the context to work in - * @return the path to the database file - */ - public static File getDatabasePath(@NonNull Context ctx) { - return ctx.getDatabasePath(DB_NAME); - } - - /** - * @return the singleton instance - */ - public static TracksDB getInstance() { - return INSTANCE; - } - - /** - * mark all tracks db that no longer exist (or are not accessible) in the file system as {@link io.github.shadow578.music_dl.db.model.TrackStatus#FileDeleted} - * - * @param ctx the context to get the files in - * @return the number of removed tracks - */ - public int markDeletedTracks(@NonNull Context ctx) { - // get all tracks that are (supposedly) downloaded - final List supposedlyDownloadedTracks = tracks().getDownloaded(); - - // check on every track if the downloaded file still exists - int count = 0; - for (TrackInfo track : supposedlyDownloadedTracks) { - // get file for this track - final Optional trackFile = StorageHelper.decodeFile(ctx, track.audioFileKey); - - // if the file could not be decoded, - // the file cannot be read OR it does not exist - // assume it was removed - if (trackFile.isPresent() - && trackFile.get().canRead() - && trackFile.get().exists()) { - // file still there, do no more - continue; - } - - // mark as deleted track - track.status = TrackStatus.FileDeleted; - tracks().update(track); - count++; - } - return count; - } - - /** - * @return the tracks DAO - */ - public abstract TracksDao tracks(); -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/db/TracksDao.java b/app/src/main/java/io/github/shadow578/music_dl/db/TracksDao.java deleted file mode 100644 index 5cbc47b..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/db/TracksDao.java +++ /dev/null @@ -1,118 +0,0 @@ -package io.github.shadow578.music_dl.db; - -import androidx.lifecycle.LiveData; -import androidx.room.Dao; -import androidx.room.Delete; -import androidx.room.Insert; -import androidx.room.OnConflictStrategy; -import androidx.room.Query; -import androidx.room.Update; - -import java.util.List; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.db.model.TrackInfo; - -/** - * DAO for tracks - */ -@Dao -@KtPorted -@SuppressWarnings("unused") -public interface TracksDao { - - /** - * observe all tracks - * - * @return the tracks that can be observed - */ - @Query("SELECT * FROM tracks ORDER BY first_added_at ASC") - LiveData> observe(); - - /** - * observe all tracks that have to be downloaded - * - * @return the tracks that can be observed - */ - @Query("SELECT * FROM tracks WHERE status = 'pending'") - LiveData> observePending(); - - /** - * get a list of all tracks - * - * @return a list of all tracks - */ - @Query("SELECT * FROM tracks") - List getAll(); - - /** - * get a list of all tracks that are marked as downloaded - * - * @return a list of all downloaded tracks - */ - @Query("SELECT * FROM tracks WHERE status = 'downloaded'") - List getDownloaded(); - - /** - * reset all tracks in downloading status back to pending - */ - @Query("UPDATE tracks SET status = 'pending' WHERE status = 'downloading'") - void resetDownloadingToPending(); - - /** - * get a track from the db - * - * @param id the id of the track - * @return the track, or null if not found - */ - @Query("SELECT * FROM tracks WHERE id = :id") - TrackInfo get(String id); - - /** - * insert a track into the db - * - * @param track the track to insert - */ - @Insert(onConflict = OnConflictStrategy.REPLACE) - void insert(TrackInfo track); - - /** - * insert multiple tracks into the db, replacing existing entries - * - * @param tracks the tracks to insert - */ - @Insert(onConflict = OnConflictStrategy.REPLACE) - void insertAll(List tracks); - - /** - * insert multiple tracks into the db, skipping existing entries - * - * @param tracks the tracks to insert - */ - @Insert(onConflict = OnConflictStrategy.IGNORE) - void insertAllNew(List tracks); - - /** - * update a track - * - * @param track the track to update - */ - @Update - void update(TrackInfo track); - - /** - * remove a single track from the db - * - * @param track the track to remove - */ - @Delete - void remove(TrackInfo track); - - /** - * remove multiple tracks from the db - * - * @param tracks the tracks to remove - */ - @Delete - void removeAll(List tracks); -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/db/model/TrackInfo.java b/app/src/main/java/io/github/shadow578/music_dl/db/model/TrackInfo.java deleted file mode 100644 index 0c1b4de..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/db/model/TrackInfo.java +++ /dev/null @@ -1,133 +0,0 @@ -package io.github.shadow578.music_dl.db.model; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.Index; -import androidx.room.PrimaryKey; - -import java.time.LocalDate; -import java.util.Objects; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.util.storage.StorageKey; - -/** - * information about a track - */ -@Entity(tableName = "tracks", - indices = { - @Index("first_added_at") - }) -@KtPorted -public class TrackInfo { - - /** - * create a new track that is still not downloaded - * - * @param id the youtube id of the track - * @param title the title of the track - * @return the track instance - */ - @NonNull - public static TrackInfo createNew(@NonNull String id, @NonNull String title) { - return new TrackInfo(id, System.currentTimeMillis(), title, null, null, null, null, StorageKey.EMPTY, StorageKey.EMPTY, TrackStatus.DownloadPending); - } - - /** - * the youtube id of the track - */ - @NonNull - @PrimaryKey - @ColumnInfo(name = "id") - public final String id; - - /** - * when this track was first added. millis timestamp, from {@link System#currentTimeMillis()} - */ - @ColumnInfo(name = "first_added_at") - public final long firstAddedAt; - - /** - * the title of the track - */ - @NonNull - @ColumnInfo(name = "track_title") - public String title; - - /** - * the name of the artist - */ - @Nullable - @ColumnInfo(name = "artist_name") - public String artist; - - /** - * the day the track was released / uploaded - */ - @Nullable - @ColumnInfo(name = "release_date") - public LocalDate releaseDate; - - /** - * duration of the track, in seconds - */ - @Nullable - @ColumnInfo(name = "duration") - public Long duration; - - /** - * the album name, if this track is part of one - */ - @Nullable - @ColumnInfo(name = "album_name") - public String albumName; - - /** - * the key of the file this track was downloaded to - */ - @NonNull - @ColumnInfo(name = "audio_file_key") - public StorageKey audioFileKey; - - /** - * the key of the track cover image file - */ - @NonNull - @ColumnInfo(name = "cover_file_key") - public StorageKey coverKey; - - /** - * is this track fully downloaded? - */ - @NonNull - @ColumnInfo(name = "status") - public TrackStatus status; - - public TrackInfo(@NonNull String id, long firstAddedAt, @NonNull String title, @Nullable String artist, @Nullable LocalDate releaseDate, @Nullable Long duration, @Nullable String albumName, @NonNull StorageKey audioFileKey, @NonNull StorageKey coverKey, @NonNull TrackStatus status) { - this.id = id; - this.firstAddedAt = firstAddedAt; - this.title = title; - this.artist = artist; - this.releaseDate = releaseDate; - this.duration = duration; - this.albumName = albumName; - this.audioFileKey = audioFileKey; - this.coverKey = coverKey; - this.status = status; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - TrackInfo trackInfo = (TrackInfo) o; - return id.equals(trackInfo.id); - } - - @Override - public int hashCode() { - return Objects.hash(id); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/db/model/TrackStatus.java b/app/src/main/java/io/github/shadow578/music_dl/db/model/TrackStatus.java deleted file mode 100644 index 8a1d43f..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/db/model/TrackStatus.java +++ /dev/null @@ -1,77 +0,0 @@ -package io.github.shadow578.music_dl.db.model; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import io.github.shadow578.music_dl.KtPorted; - -/** - * status of a track - */ -@KtPorted -public enum TrackStatus { - /** - * the track is not yet downloaded. - * The next pass of the download service will download this track - */ - DownloadPending("pending"), - - /** - * the track is currently being downloaded - */ - Downloading("downloading"), - - /** - * the track was downloaded. it will not re- download again - */ - Downloaded("downloaded"), - - /** - * the download of the track failed. it will not re- download again - */ - DownloadFailed("failed"), - - /** - * the track was deleted on the file system. it will not re- download again - * the database record remains, but with the fileKey cleared (as its invalid) - */ - FileDeleted("deleted"); - - /** - * the key, for SQL entry - */ - private final String key; - - /** - * create a track status with a key - * - * @param key the key to set - */ - TrackStatus(@NonNull String key) { - this.key = key; - } - - /** - * @return the SQL key - */ - public String key() { - return key; - } - - /** - * find a status by its key - * - * @param key the key to find - * @return the status. if not found, returns null - */ - @Nullable - public static TrackStatus findByKey(@NonNull String key) { - for (TrackStatus status : values()) { - if (status.key().equals(key)) { - return status; - } - } - - return null; - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/downloader/DownloaderException.java b/app/src/main/java/io/github/shadow578/music_dl/downloader/DownloaderException.java deleted file mode 100644 index ff8f61e..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/downloader/DownloaderException.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.github.shadow578.music_dl.downloader; - -import androidx.annotation.NonNull; - -import java.io.IOException; - -import io.github.shadow578.music_dl.KtPorted; - -/** - * exception used by {@link DownloaderService} - */ -@KtPorted -public class DownloaderException extends IOException { - - public DownloaderException(@NonNull String message) { - super(message); - } - - public DownloaderException(@NonNull String message, @NonNull Throwable cause) { - super(message, cause); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/downloader/DownloaderService.java b/app/src/main/java/io/github/shadow578/music_dl/downloader/DownloaderService.java deleted file mode 100644 index b2ae941..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/downloader/DownloaderService.java +++ /dev/null @@ -1,670 +0,0 @@ -package io.github.shadow578.music_dl.downloader; - -import android.app.Notification; -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.util.Log; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.documentfile.provider.DocumentFile; -import androidx.lifecycle.LifecycleService; - -import com.google.gson.Gson; -import com.google.gson.JsonIOException; -import com.google.gson.JsonSyntaxException; -import com.mpatric.mp3agic.ID3v2; -import com.mpatric.mp3agic.InvalidDataException; -import com.mpatric.mp3agic.NotSupportedException; -import com.mpatric.mp3agic.UnsupportedTagException; -import com.yausername.youtubedl_android.YoutubeDLResponse; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.FileReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.Locale; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.LinkedBlockingQueue; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.R; -import io.github.shadow578.music_dl.db.TracksDB; -import io.github.shadow578.music_dl.db.model.TrackInfo; -import io.github.shadow578.music_dl.db.model.TrackStatus; -import io.github.shadow578.music_dl.downloader.wrapper.MP3agicWrapper; -import io.github.shadow578.music_dl.downloader.wrapper.YoutubeDLWrapper; -import io.github.shadow578.music_dl.util.LocaleUtil; -import io.github.shadow578.music_dl.util.Util; -import io.github.shadow578.music_dl.util.notifications.NotificationChannels; -import io.github.shadow578.music_dl.util.preferences.Prefs; -import io.github.shadow578.music_dl.util.storage.StorageHelper; -import io.github.shadow578.music_dl.util.storage.StorageKey; - -/** - * tracks downloading service - */ -@KtPorted -public class DownloaderService extends LifecycleService { - - /** - * tag for logging - */ - private static final String TAG = "DLService"; - - /** - * retries for youtube-dl operations - */ - private static final int YOUTUBE_DL_RETRIES = 10; - - /** - * notification id of the progress notification - */ - private static final int PROGRESS_NOTIFICATION_ID = 123456; - - /** - * a list of all tracks that are scheduled to be downloaded. - * tracks are only removed from the set after they have been downloaded, and updated in the database - * this list is processed sequentially by {@link #downloadThread} - */ - private final BlockingQueue scheduledDownloads = new LinkedBlockingQueue<>(); - - /** - * the main download thread. runs in {@link #downloadThread()} - */ - private final Thread downloadThread = new Thread(this::downloadThread); - - /** - * gson instance - */ - private final Gson gson = new Gson(); - - /** - * notification manager, for progress notification - */ - private NotificationManagerCompat notificationManager; - - /** - * is the service currently in foreground? - */ - private boolean isInForeground = false; - - @Override - public void onCreate() { - super.onCreate(); - // ensure downloads are accessible - if (!checkDownloadsDirSet()) { - Toast.makeText(this, "Downloads directory not accessible, stopping Downloader!", Toast.LENGTH_LONG).show(); - Log.i(TAG, "downloads dir not accessible, stopping service"); - stopSelf(); - return; - } - - // create progress notification - notificationManager = NotificationManagerCompat.from(this); - - // init db and observe changes to pending tracks - Log.i(TAG, "start observing pending tracks..."); - TracksDB.init(this); - TracksDB.getInstance().tracks().observePending().observe(this, pendingTracks -> { - Log.i(TAG, String.format("pendingTracks update! size= %d", pendingTracks.size())); - - // enqueue all that are not scheduled already - boolean trackAdded = false; - for (TrackInfo track : pendingTracks) { - // ignore if track not pending - if (track == null - || scheduledDownloads.contains(track) - || track.status != TrackStatus.DownloadPending) { - continue; - } - - //enqueue the track - scheduledDownloads.add(track); - trackAdded = true; - } - - // notify downloader - if (trackAdded) { - synchronized (scheduledDownloads) { - scheduledDownloads.notifyAll(); - } - } - }); - - // start downloader thread as daemon - downloadThread.setName("io.github.shadow578.music_dl.downloader.DOWNLOAD_THREAD"); - downloadThread.setDaemon(true); - downloadThread.start(); - } - - @Override - public void onDestroy() { - Log.i(TAG, "destroying service..."); - downloadThread.interrupt(); - hideNotification(); - super.onDestroy(); - } - - @Override - protected void attachBaseContext(Context newBase) { - super.attachBaseContext(LocaleUtil.wrapContext(newBase)); - } - - /** - * check if the downloads directory is set and accessible - * - * @return is the downloads dir set and accessible? - */ - private boolean checkDownloadsDirSet() { - final StorageKey downloadsKey = Prefs.DownloadsDirectory.get(); - if (!downloadsKey.equals(StorageKey.EMPTY)) { - final Optional downloadsDir = StorageHelper.getPersistedFilePermission(this, downloadsKey, true); - return downloadsDir.isPresent() - && downloadsDir.get().exists() - && downloadsDir.get().canWrite(); - } - - return false; - } - - //region downloader top- level - - /** - * the main download thread - */ - private void downloadThread() { - try { - // reset in- progress downloads back to pending - TracksDB.getInstance().tracks().resetDownloadingToPending(); - - // init youtube-dl - Log.i(TAG, "downloader thread starting..."); - if (!YoutubeDLWrapper.init(this)) { - Log.e(TAG, "youtube-dl init failed, stopping service"); - stopSelf(); - return; - } - - // main loop - while (!Thread.interrupted()) { - // process all enqueued tracks - TrackInfo trackToDownload; - while ((trackToDownload = scheduledDownloads.peek()) != null) { - downloadTrack(trackToDownload); - scheduledDownloads.poll(); - } - - // remove notification - hideNotification(); - - // wait for changes to the set - synchronized (scheduledDownloads) { - scheduledDownloads.wait(); - } - } - } catch (InterruptedException ignored) { - } - } - - /** - * download a track - * - * @param track the track to download - */ - private void downloadTrack(@NonNull TrackInfo track) { - // double- check the track is not downloaded - final TrackInfo dbTrack = TracksDB.getInstance().tracks().get(track.id); - if (dbTrack == null || dbTrack.status != TrackStatus.DownloadPending) { - Log.i(TAG, String.format("skipping download of %s: appears to already be downloaded", track.id)); - return; - } - - // set status to downloading - track.status = TrackStatus.Downloading; - TracksDB.getInstance().tracks().update(track); - - // download the track and update the entry in the DB - final TrackDownloadFormat format = Prefs.DownloadFormat.get(); - final boolean downloadOk = download(track, format); - track.status = downloadOk ? TrackStatus.Downloaded : TrackStatus.DownloadFailed; - TracksDB.getInstance().tracks().update(track); - } - - /** - * download the track and resolve metadata - * - * @param track the track to download - * @param format the file format to download the track in - * @return was the download successful? - */ - private boolean download(@NonNull TrackInfo track, @NonNull TrackDownloadFormat format) { - TempFiles files = null; - try { - // create session - updateNotification(createStatusNotification(track, R.string.dl_status_starting_download)); - final YoutubeDLWrapper session = createSession(track, format); - files = createTempFiles(track, format); - - // download the track and metadata using youtube-dl - downloadTrack(track, session, files); - - // parse the metadata - updateNotification(createStatusNotification(track, R.string.dl_status_process_metadata)); - parseMetadata(track, files); - - // write id3v2 metadata for mp3 files - // if this fails, we do not fail the whole operation - if (format.isID3Supported() && Prefs.EnableMetadataTagging.get()) { - try { - writeID3Tag(track, files); - } catch (DownloaderException e) { - Log.e(TAG, "failed to write id3v2 tags of " + track.id + "! (this is not fatal, the rest of the download was successful)", e); - } - } - - // copy audio file to downloads dir - updateNotification(createStatusNotification(track, R.string.dl_status_finish)); - copyAudioToFinal(track, files, format); - - // copy cover to cover store - // if this fails, we do not fail the whole operation - try { - copyCoverToFinal(track, files); - } catch (DownloaderException e) { - Log.e(TAG, "failed to copy cover of " + track.id + "! (this is not fatal, the rest of the download was successful)", e); - } - return true; - } catch (DownloaderException e) { - Log.e(TAG, "download of " + track.id + " failed!", e); - return false; - } finally { - // delete temp files - if (files != null && !files.delete()) { - Log.w(TAG, "could not delete temp files for " + track.id); - } - } - } - - //endregion - - //region downloader implementation - - /** - * prepare a new youtube-dl session for the track - * - * @param track the track to prepare the session for - * @param format the file format to download the track in - * @return the youtube-dl session - * @throws DownloaderException if the cache directory could not be created (needed for the session) - */ - @NonNull - private YoutubeDLWrapper createSession(@NonNull TrackInfo track, @NonNull TrackDownloadFormat format) throws DownloaderException { - final YoutubeDLWrapper session = YoutubeDLWrapper.create(resolveVideoUrl(track)) - .cacheDir(getDownloadCacheDirectory()) - .audioOnly(format.fileExtension()); - - // enable ssl fix - if (Prefs.EnableSSLFix.get()) { - session.fixSsl(); - } - - return session; - } - - /** - * create the temporary files for the download - * - * @param track the track to create the files for - * @param format the file format to download in - * @return the tempoary files - */ - @NonNull - private TempFiles createTempFiles(@NonNull TrackInfo track, @NonNull TrackDownloadFormat format) { - final File tempAudio = Util.getTempFile("dl_" + track.id, "", getCacheDir()); - return new TempFiles(tempAudio, format.fileExtension()); - } - - /** - * invoke youtube-dl to download the track + metadata + thumbnail - * - * @param track the track to download - * @param session the current youtube-dl session - * @param files the files to write - * @throws DownloaderException if download fails - */ - private void downloadTrack(@NonNull TrackInfo track, @NonNull YoutubeDLWrapper session, @NonNull TempFiles files) throws DownloaderException { - // make sure all files to create are non- existent - files.delete(); - - // download - final YoutubeDLResponse downloadResponse = session.output(files.getAudio()) - //.overwriteExisting() - .writeMetadata() - .writeThumbnail() - .download(((progress, etaInSeconds) -> updateNotification(createProgressNotification(track, progress / 100.0, etaInSeconds))), YOUTUBE_DL_RETRIES); - if (downloadResponse == null - || !files.getAudio().exists() - || !files.getMetadataJson().exists()) { - throw new DownloaderException("youtube-dl download failed!"); - } - } - - /** - * parse the metadata file and update the values in the track - * - * @param track the track to update - * @param files the files created by youtube-dl - * @throws DownloaderException if parsing fails - */ - private void parseMetadata(@NonNull TrackInfo track, @NonNull TempFiles files) throws DownloaderException { - // check metadata file exists - if (!files.getMetadataJson().exists()) { - throw new DownloaderException("metadata file not found!"); - } - - // deserialize the file - final TrackMetadata metadata; - try (final FileReader reader = new FileReader(files.getMetadataJson())) { - metadata = gson.fromJson(reader, TrackMetadata.class); - } catch (IOException | JsonIOException | JsonSyntaxException e) { - throw new DownloaderException("deserialization of the metadata file failed", e); - } - - // set track data - metadata.getTrackTitle().ifPresent(title -> track.title = title); - metadata.getArtistName().ifPresent(artist -> track.artist = artist); - metadata.getUploadDate().ifPresent(uploadDate -> track.releaseDate = uploadDate); - if (metadata.duration != null) { - track.duration = metadata.duration; - } - - if (metadata.album != null && !metadata.album.trim().isEmpty()) { - track.albumName = metadata.album; - } - } - - /** - * copy the temporary audio file to the final destination - * - * @param track the track to download - * @param files the temporary files, of which the audio file is copied to the downloads dir - * @param format the file format that was used for the download - * @throws DownloaderException if creating the final file or the copy operation fails - */ - private void copyAudioToFinal(@NonNull TrackInfo track, @NonNull TempFiles files, @NonNull TrackDownloadFormat format) throws DownloaderException { - // check audio file exists - if (!files.getAudio().exists()) { - throw new DownloaderException("cannot find audio file to copy"); - } - - // find root folder for saving downloaded tracks to - // find using storage framework, and only allow persisted folders we can write to - final Optional downloadRoot = getDownloadsDirectory(); - if (!downloadRoot.isPresent()) { - throw new DownloaderException("failed to find downloads folder"); - } - - // create file to write the track to - final DocumentFile finalFile = downloadRoot.get().createFile(format.mimeType(), track.title + "." + format.fileExtension()); - if (finalFile == null || !finalFile.canWrite()) { - throw new DownloaderException("Could not create final output file!"); - } - - // copy the temp file to the final destination - try (final InputStream in = new FileInputStream(files.getAudio()); - final OutputStream out = getContentResolver().openOutputStream(finalFile.getUri())) { - Util.streamTransfer(in, out, 1024); - } catch (IOException e) { - // try to remove the final file - if (!finalFile.delete()) { - Log.w(TAG, "failed to delete final file on copy fail"); - } - - throw new DownloaderException(String.format(Locale.US, "error copying temp file (%s) to final destination (%s)", - files.getAudio().toString(), finalFile.getUri().toString()), - e); - } - - // set the final file in track info - track.audioFileKey = StorageHelper.encodeFile(finalFile); - } - - /** - * copy the album cover to the final destination - * - * @param track the track to copy the cover of - * @param files the files downloaded by youtube-dl - * @throws DownloaderException if copying the cover fails - */ - private void copyCoverToFinal(@NonNull TrackInfo track, @NonNull TempFiles files) throws DownloaderException { - // check thumbnail file exists - final Optional thumbnail = files.getThumbnail(); - if (!thumbnail.isPresent() || !thumbnail.get().exists()) { - throw new DownloaderException("cannot find thumbnail file"); - } - - // get covers directory - final File coverRoot = getCoverArtDirectory(); - - // create file for the thumbnail - final File coverFile = new File(coverRoot, String.format("%s_%s.webp", track.id, UUID.randomUUID())); - - // read temporary thumbnail file and write as webp in cover art directory - try (final InputStream in = new FileInputStream(thumbnail.get()); - final OutputStream out = new FileOutputStream(coverFile)) { - final Bitmap cover = BitmapFactory.decodeStream(in); - cover.compress(Bitmap.CompressFormat.WEBP, 100, out); - cover.recycle(); - } catch (IOException e) { - throw new DownloaderException("failed to save cover as webp", e); - } - - // set the cover file key in track - track.coverKey = StorageHelper.encodeFile(DocumentFile.fromFile(coverFile)); - } - - /** - * write the track metadata to the id3v2 tag of the file - * - * @param track the track data - * @param files the files downloaded by youtube-dl - * @throws DownloaderException if writing the id3 tag fails - */ - private void writeID3Tag(@NonNull TrackInfo track, @NonNull TempFiles files) throws DownloaderException { - try { - // clear all previous id3 tags, and create a new & empty one - final MP3agicWrapper mp3Wrapper = MP3agicWrapper.create(files.getAudio()); - final ID3v2 tag = mp3Wrapper - .clearAllTags() - .editTag(); - - // write basic metadata (title, artist, album, ...) - tag.setTitle(track.title); - - if (track.artist != null) { - tag.setArtist(track.artist); - } - - if (track.releaseDate != null) { - tag.setYear(String.format(Locale.US, "%04d", track.releaseDate.getYear())); - } - - if (track.albumName != null) { - tag.setAlbum(track.albumName); - } - - // set cover art (if thumbnail was downloaded) - final Optional thumbnail = files.getThumbnail(); - if (thumbnail.isPresent() && thumbnail.get().exists()) { - try (final FileInputStream src = new FileInputStream(thumbnail.get()); - final ByteArrayOutputStream out = new ByteArrayOutputStream()) { - // convert to png - final Bitmap cover = BitmapFactory.decodeStream(src); - cover.compress(Bitmap.CompressFormat.PNG, 100, out); - cover.recycle(); - - // write cover to tag - tag.setAlbumImage(out.toByteArray(), "image/png"); - } catch (IOException e) { - Log.e(TAG, "failed to convert cover image to PNG", e); - } - } - - // save the file with tags - mp3Wrapper.save(); - } catch (IOException | NotSupportedException | InvalidDataException | UnsupportedTagException e) { - throw new DownloaderException("could not write id3v2 tag to file!", e); - } - } - - //region util - - /** - * get the video url youtube-dl should use for a track - * - * @param track the track to get the video url of - * @return the video url - */ - @NonNull - private String resolveVideoUrl(@NonNull TrackInfo track) { - // youtube-dl is happy with just the track id - return track.id; - } - - /** - * get the {@link Prefs#DownloadsDirectory} of the app, using storage framework - * - * @return the optional download root directory - */ - @NonNull - public Optional getDownloadsDirectory() { - final StorageKey key = Prefs.DownloadsDirectory.get(); - if (key.equals(StorageKey.EMPTY)) { - return Optional.empty(); - } - - return StorageHelper.getPersistedFilePermission(this, key, true); - } - - /** - * get the cover art directory - * - * @return the directory to save cover art to - * @throws DownloaderException if the directory could not be created - */ - @NonNull - public File getCoverArtDirectory() throws DownloaderException { - final File coversDir = new File(getNoBackupFilesDir(), "cover_store"); - if (!coversDir.exists() && !coversDir.mkdirs()) { - throw new DownloaderException("could not create cover_store directory"); - } - return coversDir; - } - - /** - * get the youtube-dl cache directory - * - * @return the cache directory - * @throws DownloaderException if creating the directory failed - */ - @NonNull - public File getDownloadCacheDirectory() throws DownloaderException { - final File cacheDir = new File(getCacheDir(), "youtube-dl_cache"); - if (!cacheDir.exists() && !cacheDir.mkdirs()) { - throw new DownloaderException("could not create youtube-dl_cache directory"); - } - return cacheDir; - } - //endregion - //endregion - - //region status notification - - /** - * update the progress notification - * - * @param newNotification the updated notification - */ - private void updateNotification(@NonNull Notification newNotification) { - if (isInForeground) { - // already in foreground, update the notification - notificationManager.notify(PROGRESS_NOTIFICATION_ID, newNotification); - } else { - // create foreground notification - isInForeground = true; - startForeground(PROGRESS_NOTIFICATION_ID, newNotification); - } - } - - /** - * cancel the progress notification and call {@link #stopForeground(boolean)} - */ - private void hideNotification() { - if (notificationManager == null) { - return; - } - - notificationManager.cancel(PROGRESS_NOTIFICATION_ID); - stopForeground(true); - isInForeground = false; - } - - /** - * create a download progress display notification (during track download) - * - * @param track the track that is being downloaded - * @param progress the current download progress, from 0.0 to 1.0 - * @param eta the estimated download time remaining, in seconds - * @return the progress notification - */ - @NonNull - private Notification createProgressNotification(@NonNull TrackInfo track, double progress, long eta) { - return getBaseNotification() - .setContentTitle(track.title) - .setSubText(getString(R.string.dl_notification_subtext, Util.secondsToTimeString(eta))) - .setProgress(100, (int) Math.floor(progress * 100), false) - .build(); - } - - /** - * create a download prepare display notification (before or after track download) - * - * @param track the track that is being downloaded - * @param statusRes the status string - * @return the status notification - */ - @NonNull - private Notification createStatusNotification(@NonNull TrackInfo track, @StringRes int statusRes) { - return getBaseNotification() - .setContentTitle(track.title) - .setSubText(getString(statusRes)) - .setProgress(1, 0, true) - .build(); - } - - /** - * get the base notification, shared between all status notifications - * - * @return the builder, with base settings applied - */ - @NonNull - private NotificationCompat.Builder getBaseNotification() { - return new NotificationCompat.Builder(this, NotificationChannels.DownloadProgress.id()) - .setSmallIcon(R.drawable.ic_launcher_foreground) - .setShowWhen(false) - .setOnlyAlertOnce(true); - } - - //endregion -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/downloader/TempFiles.java b/app/src/main/java/io/github/shadow578/music_dl/downloader/TempFiles.java deleted file mode 100644 index 5c6aad1..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/downloader/TempFiles.java +++ /dev/null @@ -1,116 +0,0 @@ -package io.github.shadow578.music_dl.downloader; - -import androidx.annotation.NonNull; - -import java.io.File; -import java.util.Optional; - -import io.github.shadow578.music_dl.KtPorted; - -/** - * temporary files created by youtube-dl - */ -@KtPorted -public class TempFiles { - - /** - * suffix for the metadata file - */ - private static final String METADATA_FILE_SUFFIX = ".info.json"; - - /** - * suffixes (file types) for the thumbnail file - */ - private static final String[] THUMBNAIL_FILE_SUFFIXES = {".webp", ".webm", ".jpg", ".jpeg", ".png"}; - - /** - * the main audio file downloaded by youtube-dl. - * this file will be the same as {@link #convertedAudio}, but with a .tmp extension - */ - @NonNull - private final File downloadedAudio; - - /** - * the converted audio file, created by ffmpeg with the --extract-audio option - */ - @NonNull - private final File convertedAudio; - - /** - * create a new collection of temp files - * - * @param tempFile the base file. this file is not used directly - * @param format the format of the output file - */ - public TempFiles(@NonNull File tempFile, @NonNull String format) { - convertedAudio = new File(tempFile.getAbsolutePath() + "." + format); - downloadedAudio = new File(tempFile.getAbsolutePath() + ".tmp"); - } - - /** - * delete all files - * - * @return did all deletes succeed? - */ - public boolean delete() { - final Optional thumbnail = getThumbnail(); - boolean thumbnailDeleted = true; - if (thumbnail.isPresent()) { - thumbnailDeleted = thumbnail.get().delete(); - } - - return maybeDelete(downloadedAudio) - & maybeDelete(convertedAudio) - & maybeDelete(getMetadataJson()) - & thumbnailDeleted; - } - - /** - * delete the file if it still exists - * - * @param file the file to delete - * @return does the file no longer exist? - */ - private boolean maybeDelete(@NonNull File file) { - return !file.exists() || file.delete(); - } - - /** - * get the audio file. first tries to get {@link #convertedAudio}, if that does not exist gets {@link #downloadedAudio} - * - * @return the audio file - */ - @NonNull - public File getAudio() { - if (convertedAudio.exists()) { - return convertedAudio; - } - - return downloadedAudio; - } - - /** - * @return the metadata json downloaded by youtube-dl - */ - @NonNull - public File getMetadataJson() { - return new File(downloadedAudio.getAbsolutePath() + METADATA_FILE_SUFFIX); - } - - /** - * @return the thumbnail downloaded by youtube-dl, webp format - */ - @NonNull - public Optional getThumbnail() { - // check all suffixes, use the first that exists - // youtube-dl downloads the thumbnail for us, but does not tell us the file name / type (with no way to tell it what to use :|) - for (String suffix : THUMBNAIL_FILE_SUFFIXES) { - File thumbnailFile = new File(downloadedAudio.getAbsolutePath() + suffix); - if (thumbnailFile.exists()) { - return Optional.of(thumbnailFile); - } - } - - return Optional.empty(); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/downloader/TrackDownloadFormat.java b/app/src/main/java/io/github/shadow578/music_dl/downloader/TrackDownloadFormat.java deleted file mode 100644 index f9ebcea..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/downloader/TrackDownloadFormat.java +++ /dev/null @@ -1,116 +0,0 @@ -package io.github.shadow578.music_dl.downloader; - -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.R; - -/** - * file formats for track download - *

- * TODO validate all formats actually work - * TODO check if more formats support ID3 - */ -@KtPorted -public enum TrackDownloadFormat { - - /** - * mp3 (with metadata in id3 tags) - */ - MP3("audio/mp3", "mp3", true, R.string.file_format_mp3), - - - /** - * aac - */ - AAC("audio/aac", "aac", false, R.string.file_format_aac), - - /** - * webm audio - */ - WEBM("audio/weba", "weba", false, R.string.file_format_webm), - - /** - * ogg - */ - OGG("audio/ogg", "ogg", false, R.string.file_format_ogg), - - /** - * flac - */ - FLAC("audio/flac", "flac", false, R.string.file_format_flac), - - /** - * wav - */ - WAV("audio/wav", "wav", false, R.string.file_format_wav); - - /** - * audio format mime type - */ - @NonNull - private final String mimeType; - - /** - * the file extension used by downloader - */ - @NonNull - private final String fileExtension; - - /** - * does the file format support id3 tags (with mp3agic)? - */ - private final boolean isID3Supported; - - /** - * the display name string resource - */ - @StringRes - private final int displayNameRes; - - /** - * create a new track format - * - * @param mimeType audio format mime type - * @param fileExtension the file extension used by downloader - * @param isID3Supported does the file format support id3 tags (with mp3agic)? - * @param displayNameRes the display name string resource - */ - TrackDownloadFormat(@NonNull String mimeType, @NonNull String fileExtension, boolean isID3Supported, @StringRes int displayNameRes) { - this.displayNameRes = displayNameRes; - this.fileExtension = fileExtension; - this.isID3Supported = isID3Supported; - this.mimeType = mimeType; - } - - /** - * @return the display name string resource - */ - @StringRes - public int displayNameRes() { - return displayNameRes; - } - - /** - * @return the file extension used by downloader - */ - @NonNull - public String fileExtension() { - return fileExtension; - } - - /** - * @return does the file format support id3 tags (with mp3agic)? - */ - public boolean isID3Supported() { - return isID3Supported; - } - - /** - * @return audio format mime type - */ - public String mimeType() { - return mimeType; - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/downloader/TrackMetadata.java b/app/src/main/java/io/github/shadow578/music_dl/downloader/TrackMetadata.java deleted file mode 100644 index b8be259..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/downloader/TrackMetadata.java +++ /dev/null @@ -1,226 +0,0 @@ -package io.github.shadow578.music_dl.downloader; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.gson.annotations.SerializedName; - -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; -import java.util.List; -import java.util.Optional; - -import io.github.shadow578.music_dl.KtPorted; - -/** - * track metadata POJO. this is in the format that youtube-dl writes with the --write-info-json option - * a lot of data is left out, as it's not really relevant for what we're doing (stuff like track info, thumbnails, ...) - */ -@SuppressWarnings("unused") -@KtPorted -public class TrackMetadata { - - /** - * the full video title. for music videos, this often is in the format 'Artist - Song Title' - */ - @SerializedName("title") - @Nullable - public String title; - - /** - * the alternative video title. for music videos, this often is just the 'Song Title' (in contrast to {@link #title}). - * seems to be the same value as {@link #track} - */ - @SerializedName("alt_title") - @Nullable - public String alt_title; - - /** - * the upload date, in the format yyyyMMdd (that is without ANY spaces: 20200924 == 2020-09-24) - */ - @SerializedName("upload_date") - @Nullable - public String upload_date; - - /** - * the display name of the channel that uploaded the video - */ - @SerializedName("channel") - @Nullable - public String channel; - - /** - * the duration of the video, in seconds - */ - @SerializedName("duration") - @Nullable - public Long duration; - - /** - * the title of the track. this seems to be the same as {@link #alt_title} - */ - @SerializedName("track") - @Nullable - public String track; - - /** - * the name of the actual song creator (not uploader channel). - * This seems to be data from Content-ID - */ - @SerializedName("creator") - @Nullable - public String creator; - - /** - * the name of the actual song artist (not uploader channel). - * This seems to be data from Content-ID - */ - @SerializedName("artist") - @Nullable - public String artist; - - /** - * the display name of the album this track is from. - * only included for songs that are part of a album - */ - @SerializedName("album") - @Nullable - public String album; - - /** - * categories of the video (like 'Music', 'Entertainment', 'Gaming' ...) - */ - @SerializedName("categories") - @Nullable - public List categories; - - /** - * tags on the video - */ - @SerializedName("tags") - @Nullable - public List tags; - - /** - * total view count of the video - */ - @SerializedName("view_count") - @Nullable - public Long view_count; - - /** - * total likes on the video - */ - @SerializedName("like_count") - @Nullable - public Long like_count; - - /** - * total dislikes on the video - */ - @SerializedName("dislike_count") - @Nullable - public Long dislike_count; - - /** - * the average video like/dislike rating. - * range seems to be 0-5 - */ - @SerializedName("average_rating") - @Nullable - public Double average_rating; - - /** - * get the track title. tries the following fields (in that order): - * - {@link #track} - * - {@link #alt_title} - * - {@link #title} - * - * @return the track title - */ - @NonNull - public Optional getTrackTitle() { - if (notNullOrEmpty(track)) { - return Optional.of(track); - } - - if (notNullOrEmpty(alt_title)) { - return Optional.of(alt_title); - } - - if (notNullOrEmpty(title)) { - return Optional.of(title); - } - - return Optional.empty(); - } - - /** - * get the name of the primary song artist. tries the following fields (in that order): - * - {@link #artist} (first entry) - * - {@link #creator} (first entry) - * - {@link #channel} - * - * @return the artist name - */ - @NonNull - public Optional getArtistName() { - String artistList = artist; - if(!notNullOrEmpty(artistList)) - { - artistList = creator; - } - - if(notNullOrEmpty(artistList)) - { - // check if comma- delimited list - final int firstComma = artistList.indexOf(','); - if(firstComma > 0) - { - // is a list, only take first artist - artistList = artistList.substring(0, firstComma); - } - - return Optional.of(artistList); - } - - - if (notNullOrEmpty(channel)) { - return Optional.of(channel); - } - - return Optional.empty(); - } - - /** - * check a string is not null and not empty - * - * @param string the string to check - * @return is the string not null or empty? - */ - private boolean notNullOrEmpty(@Nullable String string) { - return string != null && !string.trim().isEmpty(); - } - - /** - * parse the {@link #upload_date} into a normal format - * - * @return the parsed date - */ - @NonNull - public Optional getUploadDate() { - // check if field is set - if (upload_date == null || upload_date.isEmpty()) { - return Optional.empty(); - } - - // try to parse - try { - final DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyyMMdd"); - return Optional.of(LocalDate.parse(upload_date, format)); - } catch (DateTimeParseException ignored) { - return Optional.empty(); - } - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/downloader/wrapper/MP3agicWrapper.java b/app/src/main/java/io/github/shadow578/music_dl/downloader/wrapper/MP3agicWrapper.java deleted file mode 100644 index 923f9e4..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/downloader/wrapper/MP3agicWrapper.java +++ /dev/null @@ -1,139 +0,0 @@ -package io.github.shadow578.music_dl.downloader.wrapper; - -import android.util.Log; - -import androidx.annotation.NonNull; - -import com.mpatric.mp3agic.ID3v2; -import com.mpatric.mp3agic.ID3v24Tag; -import com.mpatric.mp3agic.InvalidDataException; -import com.mpatric.mp3agic.Mp3File; -import com.mpatric.mp3agic.NotSupportedException; -import com.mpatric.mp3agic.UnsupportedTagException; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.util.Util; - -/** - * wrapper for MP3agic to make working with it on android easier - */ -@KtPorted -public class MP3agicWrapper { - - /** - * tag for logging - */ - private static final String TAG = "MP3agicW"; - - /** - * create a new wrapper instance - * - * @param file the mp3 file to read - * @return the wrapper instance - * @throws InvalidDataException thrown by mp3agic, see {@link Mp3File#Mp3File(File, int, boolean)} - * @throws IOException thrown by mp3agic, see {@link Mp3File#Mp3File(File, int, boolean)} - * @throws UnsupportedTagException thrown by mp3agic, see {@link Mp3File#Mp3File(File, int, boolean)} - */ - @NonNull - public static MP3agicWrapper create(@NonNull File file) throws InvalidDataException, IOException, UnsupportedTagException { - return new MP3agicWrapper(file); - } - - /** - * the original file passed to the constructor - */ - @NonNull - private final File originalFile; - - /** - * the mp3agic file instance - */ - @NonNull - private final Mp3File mp3; - - /** - * create a new wrapper instance - * - * @param file the mp3 file to read - * @throws InvalidDataException thrown by mp3agic, see {@link Mp3File#Mp3File(File, int, boolean)} - * @throws IOException thrown by mp3agic, see {@link Mp3File#Mp3File(File, int, boolean)} - * @throws UnsupportedTagException thrown by mp3agic, see {@link Mp3File#Mp3File(File, int, boolean)} - */ - private MP3agicWrapper(@NonNull File file) throws InvalidDataException, IOException, UnsupportedTagException { - this.originalFile = file; - mp3 = new Mp3File(file); - } - - /** - * remove all tags that mp3agic supports - * - * @return self instance - */ - public MP3agicWrapper clearAllTags() { - if (mp3.hasId3v1Tag()) { - mp3.removeId3v1Tag(); - } - if (mp3.hasId3v2Tag()) { - mp3.removeId3v2Tag(); - } - if (mp3.hasCustomTag()) { - mp3.removeCustomTag(); - } - - return this; - } - - /** - * edit the id3v2 tags on the mp3 file. - * gets a existing id3v2 tag, or creates a new one if needed - * - * @return the id3v2 tag on the mp3 - */ - @NonNull - public ID3v2 editTag() { - if (mp3.hasId3v2Tag()) { - return mp3.getId3v2Tag(); - } else { - final ID3v24Tag tag = new ID3v24Tag(); - mp3.setId3v2Tag(tag); - return tag; - } - } - - /** - * save the mp3 file, overwriting the original file - * - * @throws IOException if io operation fails - * @throws NotSupportedException if mp3agic failes to save the file (see {@link Mp3File#save(String)}) - */ - public void save() throws IOException, NotSupportedException { - File tagged = null; - try { - // create file to write to (original appended with .tagged) - tagged = new File(originalFile.getAbsolutePath() + ".tagged"); - - // save mp3 to tagged file - mp3.save(tagged.getAbsolutePath()); - - // delete original file and move tagged file to its place - if (!originalFile.delete()) { - Log.i(TAG, "could not delete original file on save!"); - } - try (final FileInputStream src = new FileInputStream(tagged.getAbsolutePath()); - final FileOutputStream out = new FileOutputStream(originalFile.getAbsolutePath(), false)) { - Util.streamTransfer(src, out, 1024); - } - } finally { - if (tagged != null) { - if (tagged.exists() && !tagged.delete()) { - Log.i(TAG, "failed to delete temporary tagged mp3 file"); - } - } - } - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/downloader/wrapper/YoutubeDLWrapper.java b/app/src/main/java/io/github/shadow578/music_dl/downloader/wrapper/YoutubeDLWrapper.java deleted file mode 100644 index dfa2beb..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/downloader/wrapper/YoutubeDLWrapper.java +++ /dev/null @@ -1,320 +0,0 @@ -package io.github.shadow578.music_dl.downloader.wrapper; - -import android.content.Context; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.yausername.ffmpeg.FFmpeg; -import com.yausername.youtubedl_android.DownloadProgressCallback; -import com.yausername.youtubedl_android.YoutubeDL; -import com.yausername.youtubedl_android.YoutubeDLException; -import com.yausername.youtubedl_android.YoutubeDLRequest; -import com.yausername.youtubedl_android.YoutubeDLResponse; - -import java.io.File; - -import io.github.shadow578.music_dl.BuildConfig; -import io.github.shadow578.music_dl.KtPorted; - -/** - * wrapper for {@link com.yausername.youtubedl_android.YoutubeDL}. - * all functions in this class should be run in a background thread only - */ -@SuppressWarnings({"unused", "UnusedReturnValue"}) -@KtPorted -public class YoutubeDLWrapper { - - /** - * tag for logging - */ - private static final String TAG = "Youtube-DL"; - - /** - * did the YoutubeDl library initialize once? in {@link #init(Context)} - */ - private static boolean initialized = false; - - /** - * create a new youtube-dl session - * - * @param videoUrl the url (or id) of the video to download. this is passed to youtube-dl directly - * @return the youtube-dl session wrapper - */ - @NonNull - public static YoutubeDLWrapper create(@NonNull String videoUrl) { - return new YoutubeDLWrapper(videoUrl); - } - - /** - * initialize the youtube-dl library - * - * @param ctx the context to work in - * @return did initialization succeed - */ - public static boolean init(@NonNull Context ctx) { - // only once - if (initialized) { - return true; - } - initialized = true; - - try { - // initialize and update youtube-dl - YoutubeDL.getInstance().init(ctx); - YoutubeDL.getInstance().updateYoutubeDL(ctx); - - // initialize FFMPEG library - FFmpeg.getInstance().init(ctx); - return true; - } catch (YoutubeDLException e) { - Log.e(TAG, "youtube-dl init failed", e); - initialized = false; - return false; - } - } - - /** - * the video url to download - */ - @NonNull - private final String videoUrl; - - /** - * the download request - */ - @NonNull - private final YoutubeDLRequest request; - - /** - * should the command output be printed to log? - */ - private boolean printOutput = false; - - /** - * create a new wrapper instance - * - * @param videoUrl the url or id of the video to download - */ - protected YoutubeDLWrapper(@NonNull String videoUrl) { - this.videoUrl = videoUrl; - this.request = new YoutubeDLRequest(videoUrl); - - // enable verbose output on debug builds - if (BuildConfig.DEBUG) { - request.addOption("--verbose"); - } - printOutput(BuildConfig.DEBUG); - } - - //region parameter wrapper - - /** - * make youtube-dl overwrite existing files, using the '--no-continue' option. - * only for use with {@link #download(DownloadProgressCallback)} functions - * - * @return self instance - */ - public YoutubeDLWrapper overwriteExisting() { - request.addOption("--no-continue"); - return this; - } - - /** - * (try) to fix ssl certificate validation errors, using the '--no-check-certificate' and '--prefer-insecure' options. - * - * @return self instance - */ - public YoutubeDLWrapper fixSsl() { - request.addOption("--no-check-certificate") - .addOption("--prefer-insecure"); - return this; - } - - /** - * download audio and video in the best quality, using '-f best'. - * only for use with {@link #download(DownloadProgressCallback)} functions - * - * @return self instance - */ - public YoutubeDLWrapper audioAndVideo() { - request.addOption("-f", "best"); - return this; - } - - /** - * download best quality video only, using '-f bestvideo'. - * only for use with {@link #download(DownloadProgressCallback)} functions - * - * @return self instance - */ - public YoutubeDLWrapper videoOnly() { - request.addOption("-f", "bestvideo"); - return this; - } - - /** - * download best quality audio only, using '-f bestaudio' with '--extract-audio'. '--audio-quality 0' and '--audio-format FORMAT'. - * only for use with {@link #download(DownloadProgressCallback)} functions - * - * @param format the format of the audio to download, like 'mp3' - * @return self instance - */ - public YoutubeDLWrapper audioOnly(@NonNull String format) { - request.addOption("-f", "bestaudio").addOption("--extract-audio") - .addOption("--audio-format", format) - .addOption("--audio-quality", 0); - return this; - } - - /** - * write the metadata to disk as a json file. path is {@link #output(File)} + .info.json - * - * @return self instance - */ - public YoutubeDLWrapper writeMetadata() { - request.addOption("--write-info-json"); - return this; - } - - /** - * write the main thumbnail to disk as webp file. path is {@link #output(File)} + .webp - * - * @return self instance - */ - public YoutubeDLWrapper writeThumbnail() { - request.addOption("--write-thumbnail"); - return this; - } - - /** - * set the file to download to, using '-o OUTPUT'. - * only for use with {@link #download(DownloadProgressCallback)} functions - * - * @param output the file to output to. unless called with {@link #overwriteExisting()}, this file must not exist - * @return self instance - */ - public YoutubeDLWrapper output(@NonNull File output) { - request.addOption("-o", output.getAbsolutePath()); - return this; - } - - /** - * set the youtube-dl cache directory, using '-cache-dir CACHE'. - * - * @param cache the cache directory - * @return self instance - */ - public YoutubeDLWrapper cacheDir(@NonNull File cache) { - request.addOption("--cache-dir", cache.getAbsolutePath()); - return this; - } - - /** - * set a option. - * only for use with {@link #download(DownloadProgressCallback)} functions - * - * @param key the parameter name (eg. '-f') - * @param value the parameter value (eg. 'best'). this may be null for options without value (like '--continue') - * @return self instance - */ - public YoutubeDLWrapper setOption(@NonNull String key, @Nullable String value) { - if (value == null) { - request.addOption(key); - } else { - request.addOption(key, value); - } - return this; - } - - /** - * get the raw {@link YoutubeDLRequest} object, to directly manipulate options. - * only use this if you know what you're doing. - * only for use with {@link #download(DownloadProgressCallback)} functions - * - * @return the youtube-dl request object - */ - @NonNull - public YoutubeDLRequest getRequest() { - return request; - } - - /** - * enable printing of the youtube-dl command output. - * by default on on DEBUG builds, and off on RELEASE builds. - * only for use with {@link #download(DownloadProgressCallback)} functions - * - * @return self instance - */ - @NonNull - public YoutubeDLWrapper printOutput(boolean print) { - printOutput = print; - return this; - } - //endregion - - //region download - - /** - * download the video using youtube-dl, with retires - * - * @param progressCallback callback to report back download progress - * @param tries the number of retries for downloading - * @return the response, or null if the download failed - */ - public YoutubeDLResponse download(@Nullable DownloadProgressCallback progressCallback, int tries) { - if (!initialized) { - throw new IllegalStateException("youtube-dl was not initialized! call YoutubeDLWrapper.init() first!"); - } - - YoutubeDLResponse response; - do { - response = download(progressCallback); - if (response != null) { - break; - } - } while (--tries > 0); - - return response; - } - - /** - * download the video using youtube-dl, without retires - * - * @param progressCallback callback to report back download progress - * @return the response, or null if the download failed - */ - public YoutubeDLResponse download(@Nullable DownloadProgressCallback progressCallback) { - if (!initialized) { - throw new IllegalStateException("youtube-dl was not initialized! call YoutubeDLWrapper.init() first!"); - } - - try { - Log.i(TAG, "downloading " + videoUrl); - final YoutubeDLResponse response = YoutubeDL.getInstance().execute(request, progressCallback); - if (printOutput) { - print(response); - } - return response; - } catch (YoutubeDLException | InterruptedException e) { - Log.e(TAG, "download of '" + videoUrl + "' using youtube-dl failed", e); - return null; - } - } - - /** - * print response details to log - * - * @param response the response to print - */ - private void print(@NonNull YoutubeDLResponse response) { - Log.i(TAG, "-------------"); - Log.i(TAG, " url: " + videoUrl); - Log.i(TAG, " command: " + response.getCommand()); - Log.i(TAG, " exit code: " + response.getExitCode()); - Log.i(TAG, " stdout: \n" + response.getOut()); - Log.i(TAG, " stderr: \n" + response.getErr()); - } - //endregion -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/share/DownloadBroadcastReceiver.java b/app/src/main/java/io/github/shadow578/music_dl/share/DownloadBroadcastReceiver.java deleted file mode 100644 index 8e7f444..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/share/DownloadBroadcastReceiver.java +++ /dev/null @@ -1,105 +0,0 @@ -package io.github.shadow578.music_dl.share; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.util.Log; - -import androidx.core.content.ContextCompat; - -import java.util.Optional; - -import io.github.shadow578.music_dl.db.TracksDB; -import io.github.shadow578.music_dl.db.model.TrackInfo; -import io.github.shadow578.music_dl.downloader.DownloaderService; -import io.github.shadow578.music_dl.util.Async; -import io.github.shadow578.music_dl.util.Util; - -/** - * a broadcast receiver for receiving download requests from other apps.
- * usage example: - *

- * final Intent broadcast = new Intent()
- *  .putExtra("io.github.shadow578.music_dl.extra.VIDEO_URL", "Youtube URL")
- *  //or .putExtra("io.github.shadow578.music_dl.extra.VIDEO_ID", "Youtube ID")
- *  .putExtra("io.github.shadow578.music_dl.extra.TITLE", "Track Title")
- *  .setAction("io.github.shadow578.music_dl.action.QUEUE_DOWNLOAD")
- *  .setPackage("io.github.shadow578.music_dl")
- *  .addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES);
- * this.sendBroadcast(broadcast);
- * 
- */ -//TODO probably dropped in KT rewrite -public class DownloadBroadcastReceiver extends BroadcastReceiver { - - /** - * action string for queueing a new track download - */ - public static final String ACTION_QUEUE_DOWNLOAD = "io.github.shadow578.music_dl.action.QUEUE_DOWNLOAD"; - - /** - * intent extra for the youtube track/video id. - * either this or VIDEO_URL is needed - */ - public static final String EXTRA_VIDEO_ID = "io.github.shadow578.music_dl.extra.VIDEO_ID"; - - /** - * intent extra for the youtube video url. - * either this or VIDEO_ID is needed - */ - public static final String EXTRA_VIDEO_URL = "io.github.shadow578.music_dl.extra.VIDEO_URL"; - - /** - * intent extra for the track title. - * this is optional - */ - public static final String EXTRA_TITLE = "io.github.shadow578.music_dl.extra.TITLE"; - - /** - * logger tag - */ - private static final String TAG = "DL_Receiver"; - - @Override - public void onReceive(Context ctx, Intent intent) { - // ensure valid parameters - if (ctx == null || intent == null) { - return; - } - - // check action - if (!ACTION_QUEUE_DOWNLOAD.equals(intent.getAction())) { - Log.w(TAG, "received intent with invalid action: " + intent.getAction()); - return; - } - - // find VIDEO_ID - Optional videoId = Optional.ofNullable(intent.getStringExtra(EXTRA_VIDEO_ID)); - - // try with VIDEO_URL - if (!videoId.isPresent()) { - final String url = intent.getStringExtra(EXTRA_VIDEO_URL); - if (url != null && !url.isEmpty()) { - videoId = Util.extractTrackId(url); - } - } - - // check if we have a id for the download - if (!videoId.isPresent()) { - Log.w(TAG, "could not find video url or id from intent extras!"); - return; - } - - // find title - final Optional title = Optional.ofNullable(intent.getStringExtra(EXTRA_TITLE)); - - // enqueue track download - final String trackId = videoId.get(); - Async.runAsync(() - -> TracksDB.init(ctx).tracks().insert(TrackInfo.createNew(trackId, title.orElse("Unknown Track")))); - - // start downloader service - final Intent serviceIntent = new Intent(ctx, DownloaderService.class); - ContextCompat.startForegroundService(ctx, serviceIntent); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/share/ShareTargetActivity.java b/app/src/main/java/io/github/shadow578/music_dl/share/ShareTargetActivity.java deleted file mode 100644 index 81023e4..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/share/ShareTargetActivity.java +++ /dev/null @@ -1,94 +0,0 @@ -package io.github.shadow578.music_dl.share; - -import android.content.Intent; -import android.os.Bundle; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; - -import java.util.Optional; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.R; -import io.github.shadow578.music_dl.downloader.DownloaderService; -import io.github.shadow578.music_dl.ui.InsertTrackUIHelper; -import io.github.shadow578.music_dl.util.Util; - -/** - * activity that handles shared youtube links (for download) - */ -@KtPorted -public class ShareTargetActivity extends AppCompatActivity { - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - final Intent intent = getIntent(); - - // action is SHARE - if (intent != null - && Intent.ACTION_SEND.equals(intent.getAction())) { - if (handleShare(intent)) { - Toast.makeText(this, R.string.share_toast_ok, Toast.LENGTH_SHORT).show(); - } else { - Toast.makeText(this, R.string.share_toast_fail, Toast.LENGTH_SHORT).show(); - } - } - - // done - finish(); - } - - /** - * handle a shared video url - * - * @param intent the share intent (with ACTION_SEND) - * @return could the url be handled successfully? - */ - private boolean handleShare(@NonNull Intent intent) { - // type is text/plain - if (!"text/plain".equalsIgnoreCase(intent.getType())) { - return false; - } - - // has EXTRA_TEXT - if (!intent.hasExtra(Intent.EXTRA_TEXT)) { - return false; - } - - // get shared url from text - final String url = intent.getStringExtra(Intent.EXTRA_TEXT); - if (url == null || url.isEmpty()) { - return false; - } - - // get video ID - final Optional trackId = Util.extractTrackId(url); - if (!trackId.isPresent()) { - return false; - } - - // get title if possible - String title = null; - if (intent.hasExtra(Intent.EXTRA_TITLE)) { - title = intent.getStringExtra(Intent.EXTRA_TITLE); - } - - // add to db as pending download - InsertTrackUIHelper.insertTrack(this, trackId.get(), title); - - // start downloader as needed - startDownloadService(); - return true; - } - - /** - * start the downloader service - */ - private void startDownloadService() { - final Intent serviceIntent = new Intent(this, DownloaderService.class); - startService(serviceIntent); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/InsertTrackUIHelper.java b/app/src/main/java/io/github/shadow578/music_dl/ui/InsertTrackUIHelper.java deleted file mode 100644 index 443730e..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/InsertTrackUIHelper.java +++ /dev/null @@ -1,81 +0,0 @@ -package io.github.shadow578.music_dl.ui; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.R; -import io.github.shadow578.music_dl.db.TracksDB; -import io.github.shadow578.music_dl.db.model.TrackInfo; -import io.github.shadow578.music_dl.util.Async; - -/** - * helper class for inserting tracks into the db from UI - */ -@KtPorted -public class InsertTrackUIHelper { - - /** - * insert a new, not yet downloaded track into the db. - * if the track already exists, displays a dialog to replace it - * - * @param ctx the context to work in - * @param id the track id - * @param title the track title - */ - public static void insertTrack(@NonNull Context ctx, @NonNull String id, @Nullable String title) { - final String fallbackTitle = ctx.getString(R.string.fallback_title); - if (title == null || title.isEmpty()) { - title = fallbackTitle; - } - final String titleF = title; - - Async.runAsync(() -> { - // check if track already in db - // if yes, show a dialog prompting the user to replace the existing track - final TrackInfo existingTrack = TracksDB.init(ctx).tracks().get(id); - if (existingTrack != null) { - Async.runOnMain(() -> showReplaceDialog(ctx, id, existingTrack.title)); - } else { - insert(ctx, id, titleF); - } - }); - } - - /** - * show a dialog prompting the user if he wants to replace a existing track - * has to be called on main ui thread - * - * @param ctx the context to work in - * @param id the track id - * @param title the track title - */ - private static void showReplaceDialog(@NonNull Context ctx, @NonNull String id, @NonNull String title) { - new AlertDialog.Builder(ctx) - .setTitle(R.string.tracks_replace_existing_title) - .setMessage(ctx.getString(R.string.tracks_replace_existing_message, title)) - .setPositiveButton(R.string.tracks_replace_existing_positive, (dialog, w) -> { - Async.runAsync(() -> insert(ctx, id, title)); - dialog.dismiss(); - }) - .setNegativeButton(R.string.tracks_replace_existing_negative, (dialog, w) -> dialog.dismiss()) - .create() - .show(); - - } - - /** - * insert a new, not yet downloaded track into the db. - * has to be called on a background thread - * - * @param ctx the context to work in - * @param id the track id - * @param title the track title - */ - private static void insert(@NonNull Context ctx, @NonNull String id, @NonNull String title) { - TracksDB.init(ctx).tracks().insert(TrackInfo.createNew(id, title)); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/base/BaseActivity.java b/app/src/main/java/io/github/shadow578/music_dl/ui/base/BaseActivity.java deleted file mode 100644 index e3f4627..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/base/BaseActivity.java +++ /dev/null @@ -1,99 +0,0 @@ -package io.github.shadow578.music_dl.ui.base; - -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.util.Log; -import android.widget.Toast; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.documentfile.provider.DocumentFile; - -import java.util.Optional; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.R; -import io.github.shadow578.music_dl.downloader.DownloaderService; -import io.github.shadow578.music_dl.util.LocaleUtil; -import io.github.shadow578.music_dl.util.preferences.Prefs; -import io.github.shadow578.music_dl.util.storage.StorageHelper; -import io.github.shadow578.music_dl.util.storage.StorageKey; - -/** - * topmost base activity. this is to be extended when creating a new activity. - * handles app- specific stuff - */ -@KtPorted -public class BaseActivity extends AppCompatActivity { - - /** - * result launcher for download directory select - */ - private ActivityResultLauncher downloadDirectorySelectLauncher; - - @Override - protected void attachBaseContext(Context newBase) { - super.attachBaseContext(LocaleUtil.wrapContext(newBase)); - } - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - // create result launcher for download directory select - downloadDirectorySelectLauncher = registerForActivityResult( - new ActivityResultContracts.OpenDocumentTree(), - treeUri -> { - if (treeUri != null) { - final DocumentFile treeFile = DocumentFile.fromTreeUri(this, treeUri); - - // check access - if (treeFile != null - && treeFile.exists() - && treeFile.canRead() - && treeFile.canWrite()) { - // persist the permission & save - final StorageKey treeKey = StorageHelper.persistFilePermission(getApplicationContext(), treeUri); - Prefs.DownloadsDirectory.set(treeKey); - Log.i("MusicDL", String.format("selected and saved new track downloads directory: %s", treeUri.toString())); - - // restart downloader - final Intent serviceIntent = new Intent(getApplication(), DownloaderService.class); - getApplication().startService(serviceIntent); - } else { - // bad selection - Toast.makeText(this, R.string.base_toast_set_download_directory_fail, Toast.LENGTH_LONG).show(); - maybeSelectDownloadsDir(true); - } - } - } - ); - } - - /** - * prompt the user to select the downloads dir, if not set - * - * @param force force select a new directory? - */ - public void maybeSelectDownloadsDir(boolean force) { - // check if downloads dir is set and accessible - final StorageKey downloadsKey = Prefs.DownloadsDirectory.get(); - if (!downloadsKey.equals(StorageKey.EMPTY) && !force) { - final Optional downloadsDir = StorageHelper.getPersistedFilePermission(this, downloadsKey, true); - if (downloadsDir.isPresent() - && downloadsDir.get().exists() - && downloadsDir.get().canWrite()) { - // download directory exists and can write, all OK! - return; - } - } - - // select directory - Toast.makeText(this, R.string.base_toast_select_download_directory, Toast.LENGTH_LONG).show(); - downloadDirectorySelectLauncher.launch(null); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/base/BaseFragment.java b/app/src/main/java/io/github/shadow578/music_dl/ui/base/BaseFragment.java deleted file mode 100644 index 0864df0..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/base/BaseFragment.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.github.shadow578.music_dl.ui.base; - -import androidx.fragment.app.Fragment; - -import io.github.shadow578.music_dl.KtPorted; - -/** - * base fragment, with some common functionality - */ -@KtPorted -public class BaseFragment extends Fragment { - - public BaseFragment() { - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/main/MainActivity.java b/app/src/main/java/io/github/shadow578/music_dl/ui/main/MainActivity.java deleted file mode 100644 index 44ed79c..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/main/MainActivity.java +++ /dev/null @@ -1,177 +0,0 @@ -package io.github.shadow578.music_dl.ui.main; - -import android.os.Bundle; - -import androidx.annotation.IdRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.lifecycle.ViewModelProvider; -import androidx.viewpager2.adapter.FragmentStateAdapter; -import androidx.viewpager2.widget.ViewPager2; - -import java.util.Arrays; -import java.util.List; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.R; -import io.github.shadow578.music_dl.databinding.ActivityMainBinding; -import io.github.shadow578.music_dl.ui.base.BaseActivity; -import io.github.shadow578.music_dl.ui.more.MoreFragment; -import io.github.shadow578.music_dl.ui.tracks.TracksFragment; - -/** - * the main activity - */ -@KtPorted -public class MainActivity extends BaseActivity { - - private final TracksFragment tracksFragment = new TracksFragment(); - private final MoreFragment moreFragment = new MoreFragment(); - - /** - * order of the sections - */ - private final List
sectionOrder = Arrays.asList( - Section.Tracks, - Section.More - ); - - /** - * the view model instance - */ - private MainViewModel model; - - /** - * the view binding instance - */ - private ActivityMainBinding b; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - b = ActivityMainBinding.inflate(getLayoutInflater()); - setContentView(b.getRoot()); - - // create model - model = new ViewModelProvider(this).get(MainViewModel.class); - - // init UI - setupBottomNavigationAndPager(); - - // select downloads dir - maybeSelectDownloadsDir(false); - } - - /** - * set up the fragment view pager and bottom navigation so they work - * together with each other & the view model - */ - private void setupBottomNavigationAndPager() { - b.fragmentPager.setAdapter(new SectionAdapter(this)); - - // setup bottom navigation listener to update model - b.bottomNav.setOnItemSelectedListener(item -> { - // find section with matching id - for (Section section : Section.values()) { - if (section.menuItemId == item.getItemId()) { - model.switchToSection(section); - return true; - } - } - - return true; - }); - - // setup viewpager listener to update model - b.fragmentPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { - @Override - public void onPageSelected(int position) { - model.switchToSection(sectionOrder.get(position)); - } - }); - - // sync model with pager and bottom navigation - model.getSection().observe(this, section -> - { - b.bottomNav.setSelectedItemId(section.menuItemId); - b.fragmentPager.setCurrentItem(sectionOrder.indexOf(section)); - b.fragmentPager.setUserInputEnabled(section.allowPagerInput); - }); - } - - /** - * get the fragment instance by section name - * - * @param section the section name - * @return the fragment - */ - @NonNull - private Fragment getSectionFragment(@NonNull Section section) { - switch (section) { - default: - case Tracks: - return tracksFragment; - case More: - return moreFragment; - } - } - - /** - * fragments / sections of the main activity - */ - public enum Section { - /** - * the tracks library fragment - */ - Tracks(R.id.nav_tracks, true), - - /** - * the more / about fragment - */ - More(R.id.nav_more, true); - - /** - * the id of the menu item for this section (in bottom navigation) - */ - @IdRes - private final int menuItemId; - - /** - * enable user input on the view pager for this section? - */ - private final boolean allowPagerInput; - - /** - * create a new section in the main activity - * - * @param menuItemId the menu item of this section - * @param allowPagerInput allow user input on the view pager? - */ - Section(@IdRes int menuItemId, boolean allowPagerInput) { - this.menuItemId = menuItemId; - this.allowPagerInput = allowPagerInput; - } - } - - /** - * adapter for the view pager - */ - private class SectionAdapter extends FragmentStateAdapter { - public SectionAdapter(@NonNull FragmentActivity fragmentActivity) { - super(fragmentActivity); - } - - @NonNull - @Override - public Fragment createFragment(int position) { - return getSectionFragment(sectionOrder.get(position)); - } - - @Override - public int getItemCount() { - return sectionOrder.size(); - } - } -} \ No newline at end of file diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/main/MainViewModel.java b/app/src/main/java/io/github/shadow578/music_dl/ui/main/MainViewModel.java deleted file mode 100644 index f4721b8..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/main/MainViewModel.java +++ /dev/null @@ -1,59 +0,0 @@ -package io.github.shadow578.music_dl.ui.main; - -import android.app.Application; -import android.content.Intent; - -import androidx.annotation.NonNull; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.downloader.DownloaderService; - -/** - * view model for the main activity - */ -@KtPorted -public class MainViewModel extends AndroidViewModel { - public MainViewModel(@NonNull Application application) { - super(application); - startDownloadService(); - } - - /** - * the currently open section - */ - @NonNull - private final MutableLiveData currentSection = new MutableLiveData<>(MainActivity.Section.Tracks); - - /** - * start the downloader service - */ - private void startDownloadService() { - final Intent serviceIntent = new Intent(getApplication(), DownloaderService.class); - getApplication().startService(serviceIntent); - } - - /** - * @return the currently visible section - */ - @NonNull - public LiveData getSection() { - return currentSection; - } - - /** - * switch the currently active section - * - * @param section the section to switch to - */ - public void switchToSection(@NonNull MainActivity.Section section) { - // ignore if same section - if (section.equals(currentSection.getValue())) { - return; - } - - currentSection.setValue(section); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/more/MoreFragment.java b/app/src/main/java/io/github/shadow578/music_dl/ui/more/MoreFragment.java deleted file mode 100644 index 7730f20..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/more/MoreFragment.java +++ /dev/null @@ -1,200 +0,0 @@ -package io.github.shadow578.music_dl.ui.more; - -import android.content.Context; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.Toast; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.documentfile.provider.DocumentFile; -import androidx.lifecycle.ViewModelProvider; - -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.LocaleOverride; -import io.github.shadow578.music_dl.R; -import io.github.shadow578.music_dl.databinding.FragmentMoreBinding; -import io.github.shadow578.music_dl.downloader.TrackDownloadFormat; -import io.github.shadow578.music_dl.ui.base.BaseFragment; - -/** - * more / about fragment - */ -@KtPorted -public class MoreFragment extends BaseFragment { - - /** - * view binding instance - */ - @SuppressWarnings("FieldCanBeLocal") - private FragmentMoreBinding b; - - /** - * view model instance - */ - private MoreViewModel model; - - /** - * launcher for export file choose action - */ - private ActivityResultLauncher chooseExportFileLauncher; - - /** - * launcher for import file choose action - */ - private ActivityResultLauncher chooseImportFileLauncher; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - b = FragmentMoreBinding.inflate(inflater, container, false); - return b.getRoot(); - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - chooseExportFileLauncher = registerForActivityResult( - new ActivityResultContracts.CreateDocument(), - uri -> { - if (uri == null) { - return; - } - - final DocumentFile file = DocumentFile.fromSingleUri(requireContext(), uri); - if (file != null && file.canWrite()) { - model.exportTracks(file); - Toast.makeText(requireContext(), R.string.backup_toast_starting, Toast.LENGTH_SHORT).show(); - } else { - Toast.makeText(requireContext(), R.string.backup_toast_failed, Toast.LENGTH_SHORT).show(); - } - }); - chooseImportFileLauncher = registerForActivityResult( - new ActivityResultContracts.OpenDocument(), - uri -> { - if (uri == null) { - return; - } - - final DocumentFile file = DocumentFile.fromSingleUri(requireContext(), uri); - if (file != null && file.canRead()) { - model.importTracks(file, requireActivity()); - Toast.makeText(requireContext(), R.string.restore_toast_starting, Toast.LENGTH_SHORT).show(); - } else { - Toast.makeText(requireContext(), R.string.restore_toast_failed, Toast.LENGTH_SHORT).show(); - } - }); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - model = new ViewModelProvider(requireActivity()).get(MoreViewModel.class); - - // about button - b.about.setOnClickListener(v -> model.openAboutPage(requireActivity())); - - // select downloads dir - b.selectDownloadsDir.setOnClickListener(v -> model.chooseDownloadsDir(requireActivity())); - - // populate language selection - setupLanguageSelection(); - - // populate download formats - setupFormatSelection(); - - // listen to ssl fix - b.enableSslFix.setOnCheckedChangeListener((buttonView, isChecked) -> model.setEnableSSLFix(isChecked)); - model.getEnableSSLFix().observe(requireActivity(), sslFix -> b.enableSslFix.setChecked(sslFix)); - - // listen to write metadata - b.enableTagging.setOnCheckedChangeListener((buttonView, isChecked) -> model.setEnableTagging(isChecked)); - model.getEnableTagging().observe(requireActivity(), enableTagging -> b.enableTagging.setChecked(enableTagging)); - - // backup / restore buttons - b.restoreTracks.setOnClickListener(v - -> chooseImportFileLauncher.launch(new String[]{"application/json"})); - b.backupTracks.setOnClickListener(v - -> chooseExportFileLauncher.launch("tracks_export.json")); - } - - /** - * setup the download format selector - */ - private void setupFormatSelection() { - // create a list of the formats and a list of display names - // both lists are in the same order - final Context ctx = requireContext(); - final List formatValues = Arrays.asList(TrackDownloadFormat.values()); - final List formatDisplayNames = formatValues.stream() - .map(format -> ctx.getString(format.displayNameRes())) - .collect(Collectors.toList()); - - // set values to display - final ArrayAdapter adapter = new ArrayAdapter<>(ctx, android.R.layout.simple_dropdown_item_1line, formatDisplayNames); - b.downloadsFormat.setAdapter(adapter); - - // set change listener - b.downloadsFormat.setOnItemClickListener((parent, view, position, id) - -> model.setDownloadFormat(formatValues.get(position))); - - // sync with model - model.getDownloadFormat().observe(requireActivity(), trackDownloadFormat - -> { - final int i = formatValues.indexOf(trackDownloadFormat); - b.downloadsFormat.setText(formatDisplayNames.get(i), false); - }); - - // always show all items - b.downloadsFormat.setOnClickListener(v -> { - adapter.getFilter().filter(null); - b.downloadsFormat.showDropDown(); - }); - } - - /** - * setup the language override selector - */ - private void setupLanguageSelection() { - // create a list of the locale overrides and a list of display names - // both lists are in the same order - final Context ctx = requireContext(); - final List localeValues = Arrays.asList(LocaleOverride.values()); - final List localeDisplayNames = localeValues.stream() - .map(locale -> locale.displayName(ctx)) - .collect(Collectors.toList()); - - // set values to display - final ArrayAdapter adapter = new ArrayAdapter<>(ctx, android.R.layout.simple_dropdown_item_1line, localeDisplayNames); - b.languageOverride.setAdapter(adapter); - - // set change listener - b.languageOverride.setOnItemClickListener((parent, view, position, id) -> { - final boolean changed = model.setLocaleOverride(localeValues.get(position)); - if (changed) { - requireActivity().recreate(); - } - }); - - // sync with model - model.getLocaleOverride().observe(requireActivity(), localeOverride -> { - final int i = localeValues.indexOf(localeOverride); - b.languageOverride.setText(localeDisplayNames.get(i), false); - }); - - // always show all items - b.languageOverride.setOnClickListener(v -> { - adapter.getFilter().filter(null); - b.languageOverride.showDropDown(); - }); - } -} \ No newline at end of file diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/more/MoreViewModel.java b/app/src/main/java/io/github/shadow578/music_dl/ui/more/MoreViewModel.java deleted file mode 100644 index 835c3df..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/more/MoreViewModel.java +++ /dev/null @@ -1,226 +0,0 @@ -package io.github.shadow578.music_dl.ui.more; - -import android.app.Activity; -import android.app.Application; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.documentfile.provider.DocumentFile; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; - -import com.mikepenz.aboutlibraries.LibsBuilder; - -import java.util.Optional; -import java.util.concurrent.atomic.AtomicBoolean; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.LocaleOverride; -import io.github.shadow578.music_dl.R; -import io.github.shadow578.music_dl.backup.BackupData; -import io.github.shadow578.music_dl.backup.BackupHelper; -import io.github.shadow578.music_dl.downloader.TrackDownloadFormat; -import io.github.shadow578.music_dl.ui.base.BaseActivity; -import io.github.shadow578.music_dl.util.Async; -import io.github.shadow578.music_dl.util.preferences.Prefs; - -/** - * view model for more fragment - */ -@KtPorted -public class MoreViewModel extends AndroidViewModel { - public MoreViewModel(@NonNull Application application) { - super(application); - } - - /** - * currently selected download format - */ - private final MutableLiveData downloadFormat = new MutableLiveData<>(Prefs.DownloadFormat.get()); - - /** - * current state of ssl_fix enable - */ - private final MutableLiveData enableSSLFix = new MutableLiveData<>(Prefs.EnableSSLFix.get()); - - /** - * current state of metadata tagging enable - */ - private final MutableLiveData enableTagging = new MutableLiveData<>(Prefs.EnableMetadataTagging.get()); - - /** - * currently selected locale override - */ - private final MutableLiveData localeOverride = new MutableLiveData<>(Prefs.LocaleOverride.get()); - - /** - * open the about page - * - * @param activity parent activity - */ - public void openAboutPage(@NonNull Activity activity) { - final LibsBuilder libs = new LibsBuilder(); - // libs.setAboutShowIcon(true); - libs.setAboutAppName(activity.getString(R.string.app_name)); - //libs.setShowLicense(true); - //libs.setShowVersion(true); - libs.start(activity); - } - - /** - * choose the download directory - * - * @param activity parent activity - */ - public void chooseDownloadsDir(@NonNull Activity activity) { - if (activity instanceof BaseActivity) { - ((BaseActivity) activity).maybeSelectDownloadsDir(true); - } - } - - /** - * import tracks from a backup file - * - * @param file the file to import from - */ - public void importTracks(@NonNull DocumentFile file, @NonNull Activity parent) { - Async.runAsync(() -> { - // read the backup data - final Optional backup = BackupHelper.readBackupData(getApplication(), file); - if (!backup.isPresent()) { - Async.runOnMain(() - -> Toast.makeText(getApplication(), R.string.restore_toast_failed, Toast.LENGTH_SHORT).show()); - return; - } - - // show confirmation dialog - Async.runOnMain(() -> { - final AtomicBoolean replaceExisting = new AtomicBoolean(false); - final int tracksCount = backup.get().tracks.size(); - new AlertDialog.Builder(parent) - .setTitle(getApplication().getString(R.string.restore_dialog_title, tracksCount)) - .setSingleChoiceItems(R.array.restore_dialog_modes, 0, (dialog, mode) - -> replaceExisting.set(mode == 1)) - .setNegativeButton(R.string.restore_dialog_negative, (dialog, w) -> dialog.dismiss()) - .setPositiveButton(R.string.restore_dialog_positive, (dialog, w) -> { - // restore the backup - Toast.makeText(getApplication(), getApplication().getString(R.string.restore_toast_success, tracksCount), Toast.LENGTH_SHORT).show(); - Async.runAsync(() - -> BackupHelper.restoreBackup(getApplication(), backup.get(), replaceExisting.get())); - }) - .show(); - }); - }); - } - - /** - * export tracks to a backup file - * - * @param file the file to export to - */ - public void exportTracks(@NonNull DocumentFile file) { - Async.runAsync(() -> { - final boolean success = BackupHelper.createBackup(getApplication(), file); - if (!success) { - Async.runOnMain(() - -> Toast.makeText(getApplication(), R.string.backup_toast_failed, Toast.LENGTH_SHORT).show()); - } - }); - } - - /** - * set the download format - * - * @param format new download format - */ - public void setDownloadFormat(@NonNull TrackDownloadFormat format) { - // ignore if no change - if (format.equals(downloadFormat.getValue())) { - return; - } - - Prefs.DownloadFormat.set(format); - downloadFormat.setValue(format); - } - - /** - * @return currently selected download format - */ - @NonNull - public LiveData getDownloadFormat() { - return downloadFormat; - } - - /** - * set ssl fix enable - * - * @param enable enable ssl fix? - */ - public void setEnableSSLFix(boolean enable) { - if(Boolean.valueOf(enable).equals(enableSSLFix.getValue())) - { - return; - } - - Prefs.EnableSSLFix.set(enable); - enableSSLFix.setValue(enable); - } - - /** - * @return current state of ssl_fix enable - */ - @NonNull - public LiveData getEnableSSLFix() { - return enableSSLFix; - } - - /** - * enable or disable metadata tagging - * - * @param enable is tagging enabled? - */ - public void setEnableTagging(boolean enable) { - if(Boolean.valueOf(enable).equals(enableTagging.getValue())) - { - return; - } - - Prefs.EnableMetadataTagging.set(enable); - enableTagging.setValue(enable); - } - - /** - * @return current state of metadata tagging enable - */ - @NonNull - public LiveData getEnableTagging() { - return enableTagging; - } - - /** - * set the currently selected locale override - * - * @param localeOverride the currently selected locale override - * @return was the locale changed? - */ - public boolean setLocaleOverride(@NonNull LocaleOverride localeOverride) { - if(localeOverride.equals(this.localeOverride.getValue())) - { - return false; - } - - Prefs.LocaleOverride.set(localeOverride); - this.localeOverride.setValue(localeOverride); - return true; - } - - /** - * @return the currently selected locale override - */ - @NonNull - public LiveData getLocaleOverride() { - return localeOverride; - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/splash/SplashScreenActivity.java b/app/src/main/java/io/github/shadow578/music_dl/ui/splash/SplashScreenActivity.java deleted file mode 100644 index 3b742a8..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/splash/SplashScreenActivity.java +++ /dev/null @@ -1,28 +0,0 @@ -package io.github.shadow578.music_dl.ui.splash; - -import android.content.Intent; -import android.os.Bundle; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.ui.main.MainActivity; -import io.github.shadow578.music_dl.util.Async; - -/** - * basic splash- screen activity. - * displays a splash- screen, then redirects the user to the correct activity - */ -@KtPorted -public class SplashScreenActivity extends AppCompatActivity { - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Async.runLaterOnMain(() -> { - startActivity(new Intent(this, MainActivity.class)); - finish(); - }, 50); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksAdapter.java b/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksAdapter.java deleted file mode 100644 index d562493..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksAdapter.java +++ /dev/null @@ -1,241 +0,0 @@ -package io.github.shadow578.music_dl.ui.tracks; - -import android.net.Uri; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.lifecycle.LifecycleOwner; -import androidx.lifecycle.LiveData; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.Glide; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Optional; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.R; -import io.github.shadow578.music_dl.databinding.RecyclerTrackViewBinding; -import io.github.shadow578.music_dl.db.model.TrackInfo; -import io.github.shadow578.music_dl.db.model.TrackStatus; -import io.github.shadow578.music_dl.util.Async; -import io.github.shadow578.music_dl.util.Util; -import io.github.shadow578.music_dl.util.storage.StorageHelper; - -/** - * recyclerview adapter for tracks livedata - */ -@KtPorted -public class TracksAdapter extends RecyclerView.Adapter { - - @NonNull - private List tracks = new ArrayList<>(); - - @NonNull - private final ItemListener clickListener; - - @NonNull - private final ItemListener reDownloadListener; - - /** - * items that should be removed later. - * key is item position, value if remove was aborted - */ - @NonNull - private final HashMap itemsToDelete = new HashMap<>(); - - public TracksAdapter(@NonNull LifecycleOwner owner, @NonNull LiveData> tracks, - @NonNull ItemListener clickListener, - @NonNull ItemListener reDownloadListener) { - this.clickListener = clickListener; - this.reDownloadListener = reDownloadListener; - tracks.observe(owner, trackInfoList -> { - if (trackInfoList == null) - return; - - this.tracks = trackInfoList; - notifyDataSetChanged(); - }); - } - - @NonNull - @Override - public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new Holder(RecyclerTrackViewBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull Holder holder, int position) { - final TrackInfo track = tracks.get(position); - - // cover - final Optional coverUri = StorageHelper.decodeUri(track.coverKey); - if (coverUri.isPresent()) { - // load cover from fs using glide - Glide.with(holder.b.coverArt) - .load(coverUri.get()) - .placeholder(R.drawable.ic_splash_foreground) - .fallback(R.drawable.ic_splash_foreground) - .into(holder.b.coverArt); - } else { - // load fallback image - Glide.with(holder.b.coverArt) - .load(R.drawable.ic_splash_foreground) - .into(holder.b.coverArt); - } - - // title - holder.b.title.setText(track.title); - - // build and set artist + album - final String albumAndArtist; - if (track.artist != null && track.albumName != null) { - albumAndArtist = holder.b.getRoot().getContext().getString(R.string.tracks_artist_and_album, - track.artist, track.albumName); - } else if (track.artist != null) { - albumAndArtist = track.artist; - } else if (track.albumName != null) { - albumAndArtist = track.albumName; - } else { - albumAndArtist = ""; - } - - holder.b.albumAndArtist.setText(albumAndArtist); - - // status icon - @DrawableRes final int statusDrawable; - switch (track.status) { - case DownloadPending: - statusDrawable = R.drawable.ic_round_timer_24; - break; - case Downloading: - statusDrawable = R.drawable.ic_downloading_black_24dp; - break; - case Downloaded: - statusDrawable = R.drawable.ic_round_check_circle_outline_24; - break; - case DownloadFailed: - statusDrawable = R.drawable.ic_round_error_outline_24; - break; - case FileDeleted: - statusDrawable = R.drawable.ic_round_remove_circle_outline_24; - break; - default: - throw new IllegalArgumentException("invalid track status: " + track.status); - } - holder.b.statusIcon.setImageResource(statusDrawable); - - // retry download button - final boolean canRetry = track.status.equals(TrackStatus.DownloadFailed) || track.status.equals(TrackStatus.FileDeleted); - holder.b.retryDownloadContainer.setVisibility(canRetry ? View.VISIBLE : View.GONE); - holder.b.retryDownloadContainer.setOnClickListener(v -> reDownloadListener.onClick(track)); - - // hide on- cover views if retry is shown - holder.b.statusIcon.setVisibility(canRetry ? View.GONE : View.VISIBLE); - holder.b.duration.setVisibility(canRetry ? View.GONE : View.VISIBLE); - - // duration - if (track.duration != null) { - holder.b.duration.setText(Util.secondsToTimeString(track.duration)); - holder.b.duration.setVisibility(View.VISIBLE); - } else { - holder.b.duration.setVisibility(View.GONE); - } - - // set click listener - holder.b.getRoot().setOnClickListener(v -> clickListener.onClick(track)); - - - // deleted mode: - // setup delete listener - holder.b.undo.setOnClickListener(v -> { - itemsToDelete.put(position, false); - notifyItemChanged(position); - }); - - // make view go into delete mode - final Boolean isToDelete = itemsToDelete.get(position); - holder.setDeletedMode(isToDelete != null && isToDelete); - } - - /** - * show a undo button for a while, then remove the item - * - * @param item the item to remove - * @param deleteCallback callback to actually delete the item. called on main thread - */ - public void deleteLater(@NonNull Holder item, @NonNull ItemListener deleteCallback) { - final int position = item.getAdapterPosition(); - final TrackInfo track = tracks.get(position); - - // mark as to delete - itemsToDelete.put(position, true); - - // delete after a delay - Async.runLaterOnMain(() -> { - if (Boolean.FALSE.equals(itemsToDelete.get(position))) { - return; - } - - // remove from map - itemsToDelete.remove(position); - - // animate removal nicely - notifyItemRemoved(position); - - // actually remove - Async.runLaterOnMain(() -> deleteCallback.onClick(track), 100); - }, 5000); - - // update to reflect new deleted state - notifyItemChanged(position); - } - - @Override - public int getItemCount() { - return tracks.size(); - } - - /** - * a view holder for the items of this adapter - */ - public static class Holder extends RecyclerView.ViewHolder { - /** - * view binding of the view this holder holds - */ - public final RecyclerTrackViewBinding b; - - public Holder(@NonNull RecyclerTrackViewBinding b) { - super(b.getRoot()); - this.b = b; - } - - /** - * set if the 'deleted mode' view should be used - * - * @param deletedMode is this item in deleted mode? - */ - public void setDeletedMode(boolean deletedMode) { - b.containerMain.setVisibility(deletedMode ? View.INVISIBLE : View.VISIBLE); - b.containerUndo.setVisibility(deletedMode ? View.VISIBLE : View.GONE); - } - } - - /** - * a click listener for track items - */ - @FunctionalInterface - public interface ItemListener { - /** - * called when a track view is selected - * - * @param track the track the view shows - */ - void onClick(@NonNull TrackInfo track); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksFragment.java b/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksFragment.java deleted file mode 100644 index 7470716..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksFragment.java +++ /dev/null @@ -1,140 +0,0 @@ -package io.github.shadow578.music_dl.ui.tracks; - -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Toast; - -import androidx.annotation.AttrRes; -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.lifecycle.ViewModelProvider; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.Optional; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.R; -import io.github.shadow578.music_dl.databinding.FragmentTracksBinding; -import io.github.shadow578.music_dl.db.TracksDB; -import io.github.shadow578.music_dl.db.model.TrackInfo; -import io.github.shadow578.music_dl.db.model.TrackStatus; -import io.github.shadow578.music_dl.ui.base.BaseFragment; -import io.github.shadow578.music_dl.util.Async; -import io.github.shadow578.music_dl.util.SwipeToDeleteCallback; -import io.github.shadow578.music_dl.util.storage.StorageHelper; - -/** - * downloaded and downloading tracks UI - */ -@KtPorted -public class TracksFragment extends BaseFragment { - - /** - * the view binding instance - */ - @SuppressWarnings("FieldCanBeLocal") - private TracksViewModel model; - - /** - * the view model instance - */ - private FragmentTracksBinding b; - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - b = FragmentTracksBinding.inflate(inflater, container, false); - return b.getRoot(); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - model = new ViewModelProvider(this).get(TracksViewModel.class); - - // setup recycler with data from model - final TracksAdapter tracksAdapter = new TracksAdapter(requireActivity(), model.getTracks(), this::playTrack, this::reDownloadTrack); - b.tracksRecycler.setLayoutManager(new LinearLayoutManager(requireContext())); - - // show empty label if no tracks available - model.getTracks().observe(requireActivity(), tracks - -> b.emptyLabel.setVisibility(tracks.size() > 0 ? View.GONE : View.VISIBLE)); - - // setup swipe to delete - final ItemTouchHelper swipeToDelete = new ItemTouchHelper(new SwipeToDeleteCallback(requireContext(), - resolveColor(R.attr.colorError), - R.drawable.ic_round_close_24, - resolveColor(R.attr.colorOnError), - 15) { - @Override - public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { - if (!(viewHolder instanceof TracksAdapter.Holder)) { - throw new IllegalStateException("got a wrong typed view holder"); - } - - tracksAdapter.deleteLater((TracksAdapter.Holder) viewHolder, track - -> Async.runAsync(() -> TracksDB.getInstance().tracks().remove(track))); - } - }); - swipeToDelete.attachToRecyclerView(b.tracksRecycler); - - - b.tracksRecycler.setAdapter(tracksAdapter); - } - - /** - * play a track - * - * @param track the track to play - */ - private void playTrack(@NonNull TrackInfo track) { - // decode track audio file key - final Optional trackUri = StorageHelper.decodeUri(track.audioFileKey); - if (!trackUri.isPresent()) { - Toast.makeText(requireContext(), R.string.tracks_play_failed, Toast.LENGTH_SHORT).show(); - return; - } - - // start external player - final Intent playIntent = new Intent(Intent.ACTION_VIEW) - .setDataAndType(trackUri.get(), "audio/*") - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK - | Intent.FLAG_ACTIVITY_SINGLE_TOP - | Intent.FLAG_GRANT_READ_URI_PERMISSION); - startActivity(playIntent); - } - - /** - * re- download a track - * - * @param track the track to re- download - */ - private void reDownloadTrack(@NonNull TrackInfo track) { - // reset status to pending - track.status = TrackStatus.DownloadPending; - - // overwrite entry in db - Async.runAsync(() -> TracksDB.init(requireContext()).tracks().insert(track)); - } - - /** - * resolve a color from a attribute - * - * @param attr the color attribute to resolve - * @return the resolved color int - */ - @ColorInt - private int resolveColor(@AttrRes int attr) { - final TypedValue value = new TypedValue(); - requireContext().getTheme().resolveAttribute(attr, value, true); - return value.data; - - } -} \ No newline at end of file diff --git a/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksViewModel.java b/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksViewModel.java deleted file mode 100644 index f8c82eb..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/ui/tracks/TracksViewModel.java +++ /dev/null @@ -1,26 +0,0 @@ -package io.github.shadow578.music_dl.ui.tracks; - -import android.app.Application; - -import androidx.annotation.NonNull; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; - -import java.util.List; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.db.TracksDB; -import io.github.shadow578.music_dl.db.model.TrackInfo; - -@KtPorted -public class TracksViewModel extends AndroidViewModel { - public TracksViewModel(@NonNull Application application) { - super(application); - TracksDB.init(getApplication()); - } - - public LiveData> getTracks(){ - return TracksDB.getInstance().tracks().observe(); - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/Async.java b/app/src/main/java/io/github/shadow578/music_dl/util/Async.java deleted file mode 100644 index 6ffc798..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/util/Async.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.github.shadow578.music_dl.util; - -import android.os.Handler; -import android.os.Looper; - -import androidx.annotation.NonNull; - -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; - -import io.github.shadow578.music_dl.KtPorted; - -/** - * async operation utilities - */ -@SuppressWarnings("unused") -@KtPorted -public class Async { - /** - * handler to run functions on the main thread. - */ - private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper()); - - /** - * run a function on the main thread - * - * @param runnable the function to run - */ - public static void runOnMain(@NonNull Runnable runnable) { - MAIN_HANDLER.post(runnable); - } - - /** - * run a function on the main thread - * - * @param runnable the function to run - * @param delay the delay, in milliseconds - */ - public static void runLaterOnMain(@NonNull Runnable runnable, long delay) { - MAIN_HANDLER.postDelayed(runnable, delay); - } - - /** - * executor for background operations - */ - private static final Executor asyncExecutor = Executors.newCachedThreadPool(); - - /** - * run a function in the background - * - * @param runnable the function to run - */ - public static void runAsync(@NonNull Runnable runnable) { - asyncExecutor.execute(runnable); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/LocaleUtil.java b/app/src/main/java/io/github/shadow578/music_dl/util/LocaleUtil.java deleted file mode 100644 index dd032c2..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/util/LocaleUtil.java +++ /dev/null @@ -1,47 +0,0 @@ -package io.github.shadow578.music_dl.util; - -import android.content.Context; -import android.content.ContextWrapper; -import android.content.res.Configuration; -import android.os.Build; -import android.os.LocaleList; - -import androidx.annotation.NonNull; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.LocaleOverride; -import io.github.shadow578.music_dl.util.preferences.Prefs; - -/** - * locale utility class - */ -@KtPorted -public class LocaleUtil { - /** - * wrap the config to use the target locale from {@link Prefs#LocaleOverride} - * - * @param originalContext the original context to use as a base - * @return the (maybe) wrapped context with the target locale - */ - @NonNull - public static Context wrapContext(@NonNull Context originalContext) { - // get preference setting - final LocaleOverride localeOverride = Prefs.LocaleOverride.get(); - - // do no overrides when using system default - if (localeOverride.equals(LocaleOverride.SystemDefault)) { - return originalContext; - } - - // create configuration with that locale - final Configuration config = new Configuration(originalContext.getResources().getConfiguration()); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - config.setLocales(new LocaleList(localeOverride.locale())); - } else { - config.setLocale(localeOverride.locale()); - } - - // wrap the context - return new ContextWrapper(originalContext.createConfigurationContext(config)); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/SwipeToDeleteCallback.java b/app/src/main/java/io/github/shadow578/music_dl/util/SwipeToDeleteCallback.java deleted file mode 100644 index d104f6c..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/util/SwipeToDeleteCallback.java +++ /dev/null @@ -1,106 +0,0 @@ -package io.github.shadow578.music_dl.util; - -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.PorterDuff; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.util.TypedValue; -import android.view.View; - -import androidx.annotation.ColorInt; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.RecyclerView; - -import io.github.shadow578.music_dl.KtPorted; - -/** - * swipe to delete handler for {@link ItemTouchHelper} - */ -@KtPorted -public abstract class SwipeToDeleteCallback extends ItemTouchHelper.SimpleCallback { - - /** - * background drawable - */ - private final Drawable background; - - /** - * icon to draw - */ - private final Drawable icon; - - /** - * margins of the icon - */ - private final int iconMargin; - - /** - * create a new handler - * - * @param ctx context to work in - * @param backgroundColor background drawable - * @param iconRes icon to draw - * @param iconTint icon tint color - * @param iconMarginDp margins of the icon, in dp - */ - public SwipeToDeleteCallback(@NonNull Context ctx, @ColorInt int backgroundColor, @DrawableRes int iconRes, @ColorInt int iconTint, int iconMarginDp) { - super(0, ItemTouchHelper.LEFT); - - // create background color drawable - background = new ColorDrawable(backgroundColor); - - // load icon drawable - icon = ContextCompat.getDrawable(ctx, iconRes); - if (icon == null) { - throw new IllegalArgumentException("could not load icon resource"); - } - - icon.setColorFilter(iconTint, PorterDuff.Mode.SRC_ATOP); - - // load margin - iconMargin = (int) TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - iconMarginDp, - ctx.getResources().getDisplayMetrics()); - } - - @Override - public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) { - // don't care - return false; - } - - @Override - public void onChildDraw(@NonNull Canvas c, @NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { - // skip for views outside of view - if (viewHolder.getAdapterPosition() < 0) { - return; - } - - final View view = viewHolder.itemView; - - // draw the red background - background.setBounds((int) (view.getRight() + dX), - view.getTop(), - view.getRight(), - view.getBottom()); - background.draw(c); - - // calculate icon bounds - final int iconLeft = view.getRight() - iconMargin - icon.getIntrinsicWidth(); - final int iconRight = view.getRight() - iconMargin; - final int iconTop = view.getTop() + ((view.getHeight() - icon.getIntrinsicHeight()) / 2); - final int iconBottom = iconTop + icon.getIntrinsicHeight(); - - // draw icon - icon.setBounds(iconLeft, iconTop, iconRight, iconBottom); - icon.draw(c); - - // do normal stuff, idk - super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/Util.java b/app/src/main/java/io/github/shadow578/music_dl/util/Util.java deleted file mode 100644 index 7bdbf8d..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/util/Util.java +++ /dev/null @@ -1,145 +0,0 @@ -package io.github.shadow578.music_dl.util; - -import androidx.annotation.NonNull; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.Locale; -import java.util.Optional; -import java.util.Random; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import io.github.shadow578.music_dl.KtPorted; - -/** - * general utility - */ -@KtPorted -public final class Util { - - //region youtube util - /** - * youtube full link ID regex. - * CG1 = ID - */ - private static final Pattern FULL_LINK_PATTERN = Pattern.compile("(?:https?://)?(?:music.)?(?:youtube.com)(?:/.*watch?\\?)(?:.*)?(?:v=)([^&]+)(?:&)?(?:.*)?"); - - /** - * youtube short link ID regex. - * CG1 = ID - */ - private static final Pattern SHORT_LINK_PATTERN = Pattern.compile("(?:https?://)?(?:youtu.be/)([^&]+)(?:&)?(?:.*)?"); - - /** - * extract the track ID from a youtube (music) url (like [music.]youtube.com/watch?v=xxxxx) - * - * @param url the url to extract the id from - * @return the id, or null if could not extract - */ - @NonNull - public static Optional extractTrackId(@NonNull String url) { - // first try full link - Matcher m = FULL_LINK_PATTERN.matcher(url); - if (m.find()) { - return Optional.ofNullable(m.group(1)); - } - - // try short link - m = SHORT_LINK_PATTERN.matcher(url); - if (m.find()) { - return Optional.ofNullable(m.group(1)); - } - - return Optional.empty(); - } - //endregion - - //region file / IO util - - /** - * non- cryptographic random number generator - */ - private static final Random random = new Random(); - - /** - * generate a random alphanumeric string with length characters - * - * @param length the length of the string to generate - * @return the random string - */ - @NonNull - public static String generateRandomAlphaNumeric(int length) { - final char[] CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".toCharArray(); - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < length; i++) - sb.append(CHARS[random.nextInt(CHARS.length)]); - - return sb.toString(); - } - - /** - * get a randomly named file in the parent directory. the file will not exist - * - * @param prefix the prefix to the file name - * @param suffix the suffix to the file name - * @param parentDirectory the parent directory to create the file in - * @return the file, with randomized filename. the file is not created by this function - */ - @NonNull - public static File getTempFile(@NonNull String prefix, @NonNull String suffix, @NonNull File parentDirectory) { - File tempFile; - do { - tempFile = new File(parentDirectory, prefix + generateRandomAlphaNumeric(32) + suffix); - } while (tempFile.exists()); - return tempFile; - } - - /** - * copy a stream - * - * @param source the source stream - * @param target the target stream - * @param bufferSize the buffer size to use, in bytes. something like 1024 bytes should work fine - * @return the total number of bytes transferred - * @throws IOException if reading the source or writing the target fails - */ - @SuppressWarnings("UnusedReturnValue") - public static long streamTransfer(@NonNull InputStream source, @NonNull OutputStream target, int bufferSize) throws IOException { - long totalBytes = 0; - final byte[] buffer = new byte[bufferSize]; - int read; - while ((read = source.read(buffer)) > 0) { - target.write(buffer, 0, read); - totalBytes += read; - } - - return totalBytes; - } - //endregion - - /** - * format a seconds value to HH:mm:ss or mm:ss format - * - * @param seconds the seconds value - * @return the formatted string - */ - @NonNull - public static String secondsToTimeString(long seconds) { - final long hours = seconds / 3600; - if (hours <= 0) { - // less than 1h, use mm:ss - return String.format(Locale.US, "%01d:%02d", - (seconds % 3600) / 60, - seconds % 60); - } - - // more than 1h, use HH:mm:ss - return String.format(Locale.US, "%01d:%02d:%02d", - hours, - (seconds % 3600) / 60, - seconds % 60); - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/notifications/NotificationChannels.java b/app/src/main/java/io/github/shadow578/music_dl/util/notifications/NotificationChannels.java deleted file mode 100644 index 74188e4..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/util/notifications/NotificationChannels.java +++ /dev/null @@ -1,148 +0,0 @@ -package io.github.shadow578.music_dl.util.notifications; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.core.app.NotificationChannelCompat; -import androidx.core.app.NotificationManagerCompat; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.R; - -/** - * class to handle notification channels - */ -@SuppressWarnings("unused") -@KtPorted -public enum NotificationChannels { - /** - * default notification channel. - *

- * only for use when testing stuff (and the actual channel is not setup yet) or for notifications that are normally not shown - */ - Default(R.string.channel_default_name, R.string.channel_default_description, NotificationManagerCompat.IMPORTANCE_DEFAULT), - - /** - * notification channel used by {@link io.github.shadow578.music_dl.downloader.DownloaderService} to show download progress - */ - DownloadProgress(R.string.channel_downloader_name, R.string.channel_downloader_description, NotificationManagerCompat.IMPORTANCE_LOW); - -// region boring background stuff - - /** - * prefix for notification channel IDs - */ - private static final String ID_PREFIX = "io.github.shadow578.youtube_dl."; - - /** - * display name of this channel. - */ - @Nullable - @StringRes - private final Integer name; - - /** - * display description of this channel. - */ - @Nullable - @StringRes - private final Integer description; - - /** - * importance int - */ - private final int importance; - - /** - * define a new notification channel. the channel will have no description and a fallback name - */ - NotificationChannels() { - name = null; - description = null; - importance = NotificationManagerCompat.IMPORTANCE_LOW; - } - - /** - * define a new notification channel. the channel will have no description and a fallback name - * - * @param importance the importance of the channel - */ - NotificationChannels(int importance) { - name = null; - description = null; - this.importance = importance; - } - - /** - * define a new notification channel. the channel will have no description - * - * @param name the display name of the channel - * @param importance the importance of the channel - */ - NotificationChannels(@StringRes int name, int importance) { - this.name = name; - description = null; - this.importance = importance; - } - - /** - * define a new notification channel - * - * @param name the display name of the channel - * @param description the display description of the channel - * @param importance the importance of the channel - */ - NotificationChannels(@StringRes int name, @StringRes int description, int importance) { - this.name = name; - this.description = description; - this.importance = importance; - } - - /** - * @return id of this channel definition - */ - @NonNull - public String id() { - return ID_PREFIX + name().toUpperCase(); - } - - /** - * create the notification channel from the definition - * - * @param ctx the context to resolve strings in - * @return the channel, with id, name, desc and importance set - */ - @NonNull - private NotificationChannelCompat createChannel(@NonNull Context ctx) { - // create channel builder - final NotificationChannelCompat.Builder channelBuilder = new NotificationChannelCompat.Builder(id(), importance); - - // set name with fallback - channelBuilder.setName(name != null ? ctx.getString(name) : id()); - - // set description - if (description != null) { - channelBuilder.setDescription(ctx.getString(description)); - } - - return channelBuilder.build(); - } - - /** - * register all notification channels - * - * @param ctx the context to register in - */ - public static void registerAll(@NonNull Context ctx) { - // get notification manager - final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(ctx); - - // register channels - for (NotificationChannels ch : NotificationChannels.values()) { - notificationManager.createNotificationChannel(ch.createChannel(ctx)); - } - } - //endregion -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/preferences/PreferenceWrapper.java b/app/src/main/java/io/github/shadow578/music_dl/util/preferences/PreferenceWrapper.java deleted file mode 100644 index c5b493b..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/util/preferences/PreferenceWrapper.java +++ /dev/null @@ -1,154 +0,0 @@ -package io.github.shadow578.music_dl.util.preferences; - -import android.content.SharedPreferences; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.gson.Gson; - -import io.github.shadow578.music_dl.KtPorted; - -/** - * wrapper class for shared preferences. init before first use using {@link #init(SharedPreferences)} - * - * @param the type of this preference - */ -@KtPorted -public class PreferenceWrapper { - - /** - * internal gson reference. all values are internally saved as json - */ - private static final Gson gson = new Gson(); - - /** - * the shared preferences to store values in - */ - private static SharedPreferences prefs; - - /** - * initialize all preference wrappers. you'll have to call this before using any preference - * - * @param prefs the shared preferences to wrap - */ - public static void init(@NonNull SharedPreferences prefs) { - PreferenceWrapper.prefs = prefs; - } - - /** - * create a new preference wrapper - * - * @param type the type of the preference - * @param key the key of the preference - * @param defaultValue the default value of the preference - * @param type of the preference - * @return the preference wrapper - */ - @NonNull - public static PreferenceWrapper create(@NonNull Class type, @NonNull String key, @NonNull T defaultValue) { - return new PreferenceWrapper<>(key, type, defaultValue); - } - - /** - * the internal type of this preference, used for gson deserialization - */ - @NonNull - private final Class type; - - /** - * the default value of the preference - */ - @NonNull - private final T defaultValue; - - /** - * the preference key of the preference - */ - @NonNull - private final String key; - - /** - * create a preference wrapper - * - * @param key the preference key of the preference - * @param type the internal type of this preference, used for gson deserialization - * @param defaultValue the default value of the preference - */ - private PreferenceWrapper(@NonNull String key, @NonNull Class type, @NonNull T defaultValue) { - this.key = key; - this.type = type; - this.defaultValue = defaultValue; - } - - /** - * get the value. if the preference is not set, uses the default value - * - * @return the preference value - */ - @NonNull - public T get() { - return get(defaultValue); - } - - /** - * get the value. if the preference is not set, uses the provided value - * - * @return the preference value - */ - @NonNull - public T get(@NonNull T defaultValueOverwrite) { - assertInit(); - final String json = prefs.getString(key, null); - if (json == null || json.isEmpty()) { - return defaultValueOverwrite; - } - - final T value = gson.fromJson(json, type); - if (value == null) { - return defaultValueOverwrite; - } - - return value; - } - - /** - * set the value of this preference - * - * @param value the value to set - */ - public void set(@Nullable T value) { - assertInit(); - - // reset if value is null - if (value == null) { - reset(); - return; - } - - // write value as json - final String json = gson.toJson(value); - prefs.edit() - .putString(key, json) - .apply(); - } - - /** - * remove this preference from the values set - */ - public void reset() { - assertInit(); - prefs.edit() - .remove(key) - .apply(); - } - - /** - * assert that the preference wrapper class was initialized. throws a exception if not - */ - private void assertInit() { - if (prefs == null) { - throw new IllegalStateException("PreferenceWrapper must be initialized using .init() before you can use it!"); - } - } -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/preferences/Prefs.java b/app/src/main/java/io/github/shadow578/music_dl/util/preferences/Prefs.java deleted file mode 100644 index ff27629..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/util/preferences/Prefs.java +++ /dev/null @@ -1,39 +0,0 @@ -package io.github.shadow578.music_dl.util.preferences; - -import io.github.shadow578.music_dl.KtPorted; -import io.github.shadow578.music_dl.LocaleOverride; -import io.github.shadow578.music_dl.downloader.TrackDownloadFormat; -import io.github.shadow578.music_dl.downloader.wrapper.YoutubeDLWrapper; -import io.github.shadow578.music_dl.util.storage.StorageKey; - -/** - * app preferences storage - */ -@KtPorted -public final class Prefs { - - /** - * the main downloads directory file key - */ - public static final PreferenceWrapper DownloadsDirectory = PreferenceWrapper.create(StorageKey.class, "downloads_dir", StorageKey.EMPTY); - - /** - * enable {@link YoutubeDLWrapper#fixSsl()} on track downloads - */ - public static final PreferenceWrapper EnableSSLFix = PreferenceWrapper.create(Boolean.class, "enable_ssl_fix", false); - - /** - * download format {@link YoutubeDLWrapper} should use for future downloads. existing downloads are not affected - */ - public static final PreferenceWrapper DownloadFormat = PreferenceWrapper.create(TrackDownloadFormat.class, "track_download_format", TrackDownloadFormat.MP3); - - /** - * enable writing ID3 metadata on downloaded tracks (if format supports it) - */ - public static final PreferenceWrapper EnableMetadataTagging = PreferenceWrapper.create(Boolean.class, "enable_meta_tagging", true); - - /** - * override for the app locale - */ - public static final PreferenceWrapper LocaleOverride = PreferenceWrapper.create(LocaleOverride.class, "locale_override", io.github.shadow578.music_dl.LocaleOverride.SystemDefault); -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/storage/StorageHelper.java b/app/src/main/java/io/github/shadow578/music_dl/util/storage/StorageHelper.java deleted file mode 100644 index 4e96ce7..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/util/storage/StorageHelper.java +++ /dev/null @@ -1,164 +0,0 @@ -package io.github.shadow578.music_dl.util.storage; - -import android.content.Context; -import android.content.Intent; -import android.content.UriPermission; -import android.net.Uri; -import android.util.Base64; - -import androidx.annotation.NonNull; -import androidx.documentfile.provider.DocumentFile; - -import java.nio.charset.StandardCharsets; -import java.util.Optional; - -import io.github.shadow578.music_dl.KtPorted; - -/** - * helper class for storage framework - */ -@SuppressWarnings("unused") -@KtPorted -public class StorageHelper { - // region URI encode / decode - - /** - * encode a uri to a string for storage in a database, preferences, ... - * the string can be converted back to a uri using {@link #decodeUri(StorageKey)} - * - * @param uri the uri to encode - * @return the encoded uri key - */ - @NonNull - public static StorageKey encodeUri(@NonNull Uri uri) { - // get file uri - String encodedUri = uri.toString(); - - // encode the uri - encodedUri = Uri.encode(encodedUri); - - // base- 64 encode to ensure android does not cry about leaked paths or something... - return new StorageKey(Base64.encodeToString(encodedUri.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP | Base64.URL_SAFE)); - } - - /** - * decode a string to a uri - * this function will only decode uris encoded with {@link #encodeUri(Uri)} - * - * @param key the encoded uri key - * @return the decoded uri - */ - @NonNull - public static Optional decodeUri(@NonNull StorageKey key) { - try { - // base- 64 decode - String uriString = new String(Base64.decode(key.toString(), Base64.NO_WRAP | Base64.URL_SAFE), StandardCharsets.UTF_8); - - // decode uri - uriString = Uri.decode(uriString); - - // check if empty - if (uriString == null || uriString.isEmpty()) { - return Optional.empty(); - } - - // parse uri - return Optional.of(Uri.parse(uriString)); - } catch (IllegalArgumentException ignored) { - return Optional.empty(); - } - } - //endregion - - //region DocumentFile encode / decode - - /** - * encode a file to a string for storage in a database, preferences, ... - * the string can be converted back to a file using {@link #decodeFile(Context, StorageKey)} - * - * @param file the file to encode - * @return the encoded file key - */ - @NonNull - public static StorageKey encodeFile(@NonNull DocumentFile file) { - return encodeUri(file.getUri()); - } - - /** - * decode a string to a file - * this function will only decode files encoded with {@link #encodeFile(DocumentFile)} - * - * @param ctx the context to create the file in - * @param key the encoded file key - * @return the decoded file - */ - @NonNull - public static Optional decodeFile(@NonNull Context ctx, @NonNull StorageKey key) { - // decode uri - final Optional uri = decodeUri(key); - return uri.map(value -> DocumentFile.fromSingleUri(ctx, value)); - - // get file - } - //endregion - - // region storage framework wrapper - - /** - * persist a file permission. see {@link android.content.ContentResolver#takePersistableUriPermission(Uri, int)}. - * uses flags {@code Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION} - * - * @param ctx the context to persist the permission in - * @param file the file to take permission of - * @return the key for this file. can be read back using {@link #getPersistedFilePermission(Context, StorageKey, boolean)} - */ - @NonNull - public static StorageKey persistFilePermission(@NonNull Context ctx, @NonNull DocumentFile file) { - return persistFilePermission(ctx, file.getUri()); - } - - /** - * persist a file permission. see {@link android.content.ContentResolver#takePersistableUriPermission(Uri, int)}. - * uses flags {@code Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION} - * - * @param ctx the context to persist the permission in - * @param uri the uri to take permission of - * @return the key for this uri. can be read back using {@link #getPersistedFilePermission(Context, StorageKey, boolean)} - */ - @NonNull - public static StorageKey persistFilePermission(@NonNull Context ctx, @NonNull Uri uri) { - ctx.getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - return encodeUri(uri); - } - - /** - * find a persisted file with a given key - * - * @param ctx the context to check in - * @param key the key of the file to find - * @param mustExist must the file exist? - * @return the file found - */ - @NonNull - public static Optional getPersistedFilePermission(@NonNull Context ctx, @NonNull StorageKey key, boolean mustExist) { - // decode the uri from key - final Optional targetUri = decodeUri(key); - //noinspection OptionalIsPresent - if (!targetUri.isPresent()) { - return Optional.empty(); - } - - // find the persistent uri with that key - return ctx.getContentResolver().getPersistedUriPermissions() - .stream() - .filter(UriPermission::isWritePermission) - .filter(uri -> uri.getUri().equals(targetUri.get())) - .map(uri -> DocumentFile.fromTreeUri(ctx, uri.getUri())) - .filter(doc -> doc != null && doc.canRead() && doc.canWrite()) - .filter(doc -> !mustExist || doc.exists()) - .findFirst(); - } - - //endregion - -} diff --git a/app/src/main/java/io/github/shadow578/music_dl/util/storage/StorageKey.java b/app/src/main/java/io/github/shadow578/music_dl/util/storage/StorageKey.java deleted file mode 100644 index 4f2c001..0000000 --- a/app/src/main/java/io/github/shadow578/music_dl/util/storage/StorageKey.java +++ /dev/null @@ -1,42 +0,0 @@ -package io.github.shadow578.music_dl.util.storage; - -import androidx.annotation.NonNull; - -import io.github.shadow578.music_dl.KtPorted; - -/** - * a storage key, used by {@link StorageHelper} - */ -@KtPorted -public class StorageKey { - - /** - * a empty storage key - */ - public static final StorageKey EMPTY = new StorageKey(""); - - /** - * the internal string key - */ - private final String key; - - /** - * create a new storage key - * - * @param key the key of the file - */ - public StorageKey(String key) { - this.key = key; - } - - /** - * get the key as a string - * - * @return the string key - */ - @NonNull - @Override - public String toString() { - return key; - } -} diff --git a/app/src/test/java/io/github/shadow578/music_dl/downloader/TrackMetadataTest.java b/app/src/test/java/io/github/shadow578/music_dl/downloader/TrackMetadataTest.java deleted file mode 100644 index 310d787..0000000 --- a/app/src/test/java/io/github/shadow578/music_dl/downloader/TrackMetadataTest.java +++ /dev/null @@ -1,131 +0,0 @@ -package io.github.shadow578.music_dl.downloader; - -import com.google.gson.Gson; - -import org.junit.Before; -import org.junit.Test; - -import java.time.LocalDate; - -import static com.github.npathai.hamcrestopt.OptionalMatchers.isEmpty; -import static com.github.npathai.hamcrestopt.OptionalMatchers.isPresentAnd; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsInAnyOrder; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.notNullValue; - -/** - * test for {@link TrackMetadata} - */ -public class TrackMetadataTest { - - private TrackMetadata meta; - - /** - * deserialize a mock metadata json object - */ - @Before - public void deserializeMetadataJson() { - // prepare json (totally not based on real data) - final String json = "{\"track\":\"The Commerce\",\"tags\":[\"Otseit\",\"The Commerce\"],\"view_count\":128898492,\"average_rating\":4.888588,\"upload_date\":\"20200924\",\"channel\":\"Otseit\",\"duration\":192,\"creator\":\"Otseit\",\"dislike_count\":34855,\"artist\":\"Otseit,AWatson\",\"album\":\"The Commerce\",\"title\":\"Otseit - The Commerce (Official Music Video)\",\"alt_title\":\"Otseit - The Commerce\",\"categories\":[\"Music\"],\"like_count\":1216535}"; - /* -{ - "track": "The Commerce", - "tags": [ - "Otseit", - "The Commerce" - ], - "view_count": 128898492, - "average_rating": 4.888588, - "upload_date": "20200924", - "channel": "Otseit", - "duration": 192, - "creator": "Otseit", - "dislike_count": 34855, - "artist": "Otseit,AWatson", - "album": "The Commerce", - "title": "Otseit - The Commerce (Official Music Video)", - "alt_title": "Otseit - The Commerce", - "categories": [ - "Music" - ], - "like_count": 1216535 -}*/ - - // deserialize the object using (a default) GSON - final Gson gson = new Gson(); - meta = gson.fromJson(json, TrackMetadata.class); - } - - /** - * testing if fields are deserialized correctly - */ - @Test - public void shouldDeserialize() { - // check fields are correct - assertThat(meta, notNullValue(TrackMetadata.class)); - assertThat(meta.track, equalTo("The Commerce")); - assertThat(meta.tags, containsInAnyOrder("Otseit", "The Commerce")); - assertThat(meta.view_count, equalTo(128898492L)); - assertThat(meta.average_rating, equalTo(4.888588)); - assertThat(meta.upload_date, equalTo("20200924")); - assertThat(meta.channel, equalTo("Otseit")); - assertThat(meta.duration, equalTo(192L)); - assertThat(meta.creator, equalTo("Otseit")); - assertThat(meta.dislike_count, equalTo(34855L)); - assertThat(meta.artist, equalTo("Otseit,AWatson")); - assertThat(meta.album, equalTo("The Commerce")); - assertThat(meta.title, equalTo("Otseit - The Commerce (Official Music Video)")); - assertThat(meta.alt_title, equalTo("Otseit - The Commerce")); - assertThat(meta.categories, containsInAnyOrder("Music")); - assertThat(meta.like_count, equalTo(1216535L)); - } - - /** - * {@link TrackMetadata#getTrackTitle()} - */ - @Test - public void shouldGetTitle() { - assertThat(meta.getTrackTitle(), isPresentAnd(equalTo("The Commerce"))); - - meta.track = null; - assertThat(meta.getTrackTitle(), isPresentAnd(equalTo("Otseit - The Commerce"))); - - meta.alt_title = null; - assertThat(meta.getTrackTitle(), isPresentAnd(equalTo("Otseit - The Commerce (Official Music Video)"))); - - meta.title = null; - assertThat(meta.getTrackTitle(), isEmpty()); - } - - /** - * {@link TrackMetadata#getArtistName()} - */ - @Test - public void shouldGetArtistName() { - assertThat(meta.getArtistName(), isPresentAnd(equalTo("Otseit"))); - - meta.artist = null; - assertThat(meta.getArtistName(), isPresentAnd(equalTo("Otseit"))); - - meta.creator = null; - assertThat(meta.getArtistName(), isPresentAnd(equalTo("Otseit"))); - - meta.channel = null; - assertThat(meta.getArtistName(), isEmpty()); - } - - /** - * {@link TrackMetadata#getUploadDate()} - */ - @Test - public void shouldGetUploadDate() { - assertThat(meta.getUploadDate(), isPresentAnd(equalTo(LocalDate.of(2020, 9, 24)))); - - meta.upload_date = null; - assertThat(meta.getUploadDate(), isEmpty()); - - meta.upload_date = "foobar"; - assertThat(meta.getUploadDate(), isEmpty()); - } -} diff --git a/app/src/test/java/io/github/shadow578/music_dl/downloader/wrapper/YoutubeDLWrapperTest.java b/app/src/test/java/io/github/shadow578/music_dl/downloader/wrapper/YoutubeDLWrapperTest.java deleted file mode 100644 index 46e007c..0000000 --- a/app/src/test/java/io/github/shadow578/music_dl/downloader/wrapper/YoutubeDLWrapperTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package io.github.shadow578.music_dl.downloader.wrapper; - -import com.yausername.youtubedl_android.YoutubeDLRequest; - -import org.junit.Test; - -import java.io.File; -import java.util.List; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.hasItem; -import static org.hamcrest.Matchers.hasItems; -import static org.hamcrest.Matchers.notNullValue; - -/** - * test for {@link YoutubeDLWrapper} parameter creation - */ -public class YoutubeDLWrapperTest { - - /** - * test parameter list resulting from calls to wrapper function - */ - @Test - public void shouldBuildParameterList() { - // create session with some parameters - final String videoUrl = "aaBBccDD"; - final File targetFile = new File("/tmp/download/test.mp3"); - final File cacheDir = new File("/tmp/download/cache/"); - - final YoutubeDLWrapper session = YoutubeDLWrapper.create(videoUrl) - .overwriteExisting() - .fixSsl() - .audioAndVideo() - .writeMetadata() - .writeThumbnail() - .output(targetFile) - .cacheDir(cacheDir); - - // check internal request has correct parameters - final YoutubeDLRequest request = session.getRequest(); - assertThat(request, notNullValue(YoutubeDLRequest.class)); - - List args = request.buildCommand(); - assertThat(args, hasItem("--no-continue")); - assertThat(args, hasItems("--no-check-certificate", "--prefer-insecure")); - assertThat(args, hasItems("-f", "best")); - assertThat(args, hasItem("--write-info-json")); - assertThat(args, hasItem("--write-thumbnail")); - assertThat(args, hasItems("-o", targetFile.getAbsolutePath())); - assertThat(args, hasItems("--cache-dir", cacheDir.getAbsolutePath())); - assertThat(args, hasItem(videoUrl)); - - // audio only - session.audioOnly("mp3"); - args = session.getRequest().buildCommand(); - assertThat(args, hasItems("-f", "bestaudio")); - assertThat(args, hasItems("--extract-audio")); - assertThat(args, hasItems("--audio-quality", "0")); - assertThat(args, hasItems("--audio-format", "mp3")); - - - // video only - session.videoOnly(); - args = session.getRequest().buildCommand(); - assertThat(args, hasItems("-f", "bestvideo")); - - // custom option - session.setOption("--foo", "bar") - .setOption("--yee", null); - args = session.getRequest().buildCommand(); - assertThat(args, hasItems("--foo", "bar", "--yee")); - } -} diff --git a/app/src/test/java/io/github/shadow578/music_dl/util/UtilTest.java b/app/src/test/java/io/github/shadow578/music_dl/util/UtilTest.java deleted file mode 100644 index 324b843..0000000 --- a/app/src/test/java/io/github/shadow578/music_dl/util/UtilTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package io.github.shadow578.music_dl.util; - -import org.junit.Test; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; - -import static com.github.npathai.hamcrestopt.OptionalMatchers.isEmpty; -import static com.github.npathai.hamcrestopt.OptionalMatchers.isPresentAnd; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.equalToIgnoringCase; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.notNullValue; - -/** - * test for {@link Util} - */ -public class UtilTest { - - /** - * {@link Util#extractTrackId(String)} - */ - @Test - public void shouldExtractVideoId() { - // youtube full - assertThat(Util.extractTrackId("https://www.youtube.com/watch?v=6Xs26b4RSu4"), - isPresentAnd(equalToIgnoringCase("6Xs26b4RSu4"))); - - // youtube short (https) - assertThat(Util.extractTrackId("https://youtu.be/6Xs26b4RSu4"), - isPresentAnd(equalToIgnoringCase("6Xs26b4RSu4"))); - - // youtube short (http) - assertThat(Util.extractTrackId("http://youtu.be/6Xs26b4RSu4"), - isPresentAnd(equalToIgnoringCase("6Xs26b4RSu4"))); - - // youtube short (no protocol) - assertThat(Util.extractTrackId("youtu.be/6Xs26b4RSu4"), - isPresentAnd(equalToIgnoringCase("6Xs26b4RSu4"))); - - // youtube full with playlist - assertThat(Util.extractTrackId("https://www.youtube.com/watch?v=6Xs26b4RSu4&list=RD6Xs26b4RSu4&start_radio=1&rv=6Xs26b4RSu4&t=0"), - isPresentAnd(equalToIgnoringCase("6Xs26b4RSu4"))); - - // youtube music - assertThat(Util.extractTrackId("https://music.youtube.com/watch?v=wbJwhx29O5U&list=RDAMVMwbJwhx29O5U"), - isPresentAnd(equalToIgnoringCase("wbJwhx29O5U"))); - - // youtube music by share dialog - assertThat(Util.extractTrackId("https://music.youtube.com/watch?v=wbJwhx29O5U&feature=share"), - isPresentAnd(equalToIgnoringCase("wbJwhx29O5U"))); - - // invalid link - assertThat(Util.extractTrackId("foobar"), isEmpty()); - } - - /** - * {@link Util#generateRandomAlphaNumeric(int)} - */ - @Test - public void shouldGenerateRandomString() { - final String random = Util.generateRandomAlphaNumeric(128); - assertThat(random, notNullValue(String.class)); - assertThat(random.length(), is(128)); - assertThat(random.isEmpty(), is(false)); - } - - /** - * {@link Util#streamTransfer(InputStream, OutputStream, int)} - */ - @Test - public void shouldTransferStream() throws IOException { - final InputStream in = new ByteArrayInputStream("foobar".getBytes()); - final ByteArrayOutputStream out = new ByteArrayOutputStream(); - - Util.streamTransfer(in, out, 1024); - - assertThat(out.toString(), equalTo("foobar")); - } - - /** - * {@link Util#secondsToTimeString(long)} - */ - @Test - public void shouldConvertSecondsToString() { - // < 1h - assertThat(Util.secondsToTimeString(620), equalTo("10:20")); - assertThat(Util.secondsToTimeString(520), equalTo("8:40")); - - // > 1h - assertThat(Util.secondsToTimeString(7300), equalTo("2:01:40")); - - // > 10d - assertThat(Util.secondsToTimeString(172800), equalTo("48:00:00")); - } -} From 9101c8e999a5b44e6ba8b9de8e4e9355e073a630 Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Wed, 4 Aug 2021 10:51:49 +0200 Subject: [PATCH 05/22] fix wrong references & packages --- app/src/main/AndroidManifest.xml | 19 +++--------- .../github/shadow578/yodel/LocaleOverride.kt | 1 - .../io/github/shadow578/yodel/YodelApp.kt | 7 ++--- .../shadow578/yodel/backup/BackupData.kt | 5 ++-- .../shadow578/yodel/backup/BackupHelper.kt | 23 ++++++--------- .../shadow578/yodel/db/DBTypeConverters.kt | 1 + .../io/github/shadow578/yodel/db/TracksDao.kt | 8 ----- .../yodel/downloader/DownloadService.kt | 29 ++++++------------- .../yodel/downloader/DownloaderException.kt | 1 - .../yodel/downloader/TrackDownloadFormat.kt | 2 +- .../downloader/wrapper/YoutubeDLWrapper.kt | 4 +-- .../shadow578/yodel/ui/InsertTrackUIHelper.kt | 8 ++--- .../shadow578/yodel/ui/base/BaseActivity.kt | 9 ++---- .../shadow578/yodel/ui/main/MainActivity.kt | 7 ++--- .../shadow578/yodel/ui/more/MoreFragment.kt | 14 ++++----- .../shadow578/yodel/ui/more/MoreViewModel.kt | 15 ++++------ .../yodel/ui/share/ShareTargetActivity.kt | 2 +- .../yodel/ui/splash/SplashScreenActivity.kt | 2 +- .../yodel/ui/tracks/TracksAdapter.kt | 16 +++++----- .../yodel/ui/tracks/TracksFragment.kt | 21 +++++--------- .../yodel/util/NotificationChannels.kt | 5 ++-- app/src/main/res/values-night/themes.xml | 2 +- app/src/main/res/values/themes.xml | 4 +-- app/src/main/res/xml/backup_descriptor.xml | 2 +- 24 files changed, 73 insertions(+), 134 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 370d4c3..d48f1e6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ + package="io.github.shadow578.yodel"> @@ -8,7 +8,7 @@ @@ -33,7 +33,7 @@ - + @@ -41,17 +41,6 @@ - - - - - - - diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/LocaleOverride.kt b/app/src/main/kotlin/io/github/shadow578/yodel/LocaleOverride.kt index a54761c..89f2731 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/LocaleOverride.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/LocaleOverride.kt @@ -1,7 +1,6 @@ package io.github.shadow578.yodel import android.content.Context -import io.github.shadow578.music_dl.R import java.util.* /** diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/YodelApp.kt b/app/src/main/kotlin/io/github/shadow578/yodel/YodelApp.kt index bf37309..2edc235 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/YodelApp.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/YodelApp.kt @@ -3,9 +3,8 @@ package io.github.shadow578.yodel import android.app.Application import android.util.Log import androidx.preference.PreferenceManager -import io.github.shadow578.music_dl.db.TracksDB -import io.github.shadow578.yodel.util.NotificationChannels -import io.github.shadow578.yodel.util.launchIO +import io.github.shadow578.yodel.db.TracksDB +import io.github.shadow578.yodel.util.* import io.github.shadow578.yodel.util.preferences.PreferenceWrapper /** @@ -19,7 +18,7 @@ class YodelApp : Application() { // find tracks that were deleted launchIO { - val removedCount = TracksDB.init(this@YodelApp).markDeletedTracks(this@YodelApp) + val removedCount = TracksDB.get(this@YodelApp).markDeletedTracks(this@YodelApp) Log.i("Yodel", "found $removedCount tracks that were deleted in the file system") } } diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupData.kt b/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupData.kt index f3941b9..241dcc4 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupData.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupData.kt @@ -1,17 +1,18 @@ package io.github.shadow578.yodel.backup -import io.github.shadow578.music_dl.backup.BackupHelper -import io.github.shadow578.music_dl.db.model.TrackInfo +import io.github.shadow578.yodel.db.model.TrackInfo import java.time.LocalDateTime /** * backup data written by [BackupHelper] */ +@Suppress("unused") class BackupData( /** * the tracks in this backup */ val tracks: List, + /** * the time the backup was created */ diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupHelper.kt b/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupHelper.kt index 19cc80d..36a496a 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupHelper.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupHelper.kt @@ -3,15 +3,10 @@ package io.github.shadow578.yodel.backup import android.content.Context import android.util.Log import androidx.documentfile.provider.DocumentFile -import com.google.gson.GsonBuilder -import com.google.gson.JsonIOException -import com.google.gson.JsonSyntaxException -import io.github.shadow578.music_dl.db.TracksDB -import java.io.IOException -import java.io.InputStreamReader -import java.io.OutputStreamWriter -import java.time.LocalDate -import java.time.LocalDateTime +import com.google.gson.* +import io.github.shadow578.yodel.db.TracksDB +import java.io.* +import java.time.* import java.util.* /** @@ -41,8 +36,8 @@ object BackupHelper { */ fun createBackup(ctx: Context, file: DocumentFile): Boolean { // get all tracks in DB - val tracks = TracksDB.init(ctx).tracks().all - if (tracks.size <= 0) return false + val tracks = TracksDB.get(ctx).tracks().all + if (tracks.isEmpty()) return false // create backup data val backup = BackupData(tracks, LocalDateTime.now()) @@ -97,12 +92,12 @@ object BackupHelper { */ fun restoreBackup(ctx: Context, data: BackupData, replaceExisting: Boolean) { // check there are tracks to import - if (data.tracks.size <= 0) return + if (data.tracks.isEmpty()) return // insert the tracks if (replaceExisting) - TracksDB.init(ctx).tracks().insertAll(data.tracks) + TracksDB.get(ctx).tracks().insertAll(data.tracks) else - TracksDB.init(ctx).tracks().insertAllNew(data.tracks) + TracksDB.get(ctx).tracks().insertAllNew(data.tracks) } } \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/db/DBTypeConverters.kt b/app/src/main/kotlin/io/github/shadow578/yodel/db/DBTypeConverters.kt index 5152329..d53060b 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/db/DBTypeConverters.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/db/DBTypeConverters.kt @@ -8,6 +8,7 @@ import java.time.LocalDate /** * type converters for room */ +@Suppress("unused") class DBTypeConverters { //region StorageKey @TypeConverter diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/db/TracksDao.kt b/app/src/main/kotlin/io/github/shadow578/yodel/db/TracksDao.kt index 514af54..20943ba 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/db/TracksDao.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/db/TracksDao.kt @@ -95,12 +95,4 @@ interface TracksDao { */ @Delete fun remove(track: TrackInfo) - - /** - * remove multiple tracks from the db - * - * @param tracks the tracks to remove - */ - @Delete - fun removeAll(tracks: List) } \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt index 45e398b..74d733f 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt @@ -2,36 +2,25 @@ package io.github.shadow578.yodel.downloader import android.app.Notification import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory +import android.graphics.* import android.util.Log import android.widget.Toast import androidx.annotation.StringRes -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat +import androidx.core.app.* import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.LifecycleService -import com.google.gson.Gson -import com.google.gson.JsonIOException -import com.google.gson.JsonSyntaxException -import com.mpatric.mp3agic.InvalidDataException -import com.mpatric.mp3agic.NotSupportedException -import com.mpatric.mp3agic.UnsupportedTagException -import io.github.shadow578.music_dl.R +import com.google.gson.* +import com.mpatric.mp3agic.* +import io.github.shadow578.yodel.R import io.github.shadow578.yodel.db.TracksDB -import io.github.shadow578.yodel.db.model.TrackInfo -import io.github.shadow578.yodel.db.model.TrackStatus -import io.github.shadow578.yodel.downloader.wrapper.MP3agicWrapper -import io.github.shadow578.yodel.downloader.wrapper.YoutubeDLWrapper +import io.github.shadow578.yodel.db.model.* +import io.github.shadow578.yodel.downloader.wrapper.* import io.github.shadow578.yodel.util.* import io.github.shadow578.yodel.util.preferences.Prefs -import io.github.shadow578.yodel.util.storage.StorageKey -import io.github.shadow578.yodel.util.storage.encodeToKey -import io.github.shadow578.yodel.util.storage.getPersistedFilePermission +import io.github.shadow578.yodel.util.storage.* import java.io.* import java.util.* -import java.util.concurrent.BlockingQueue -import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.* import kotlin.math.floor /** diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloaderException.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloaderException.kt index 51ab713..d7fec27 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloaderException.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloaderException.kt @@ -1,6 +1,5 @@ package io.github.shadow578.yodel.downloader -import io.github.shadow578.music_dl.downloader.DownloaderService import java.io.IOException /** diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackDownloadFormat.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackDownloadFormat.kt index 2f8e090..1d5014c 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackDownloadFormat.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackDownloadFormat.kt @@ -1,7 +1,7 @@ package io.github.shadow578.yodel.downloader import androidx.annotation.StringRes -import io.github.shadow578.music_dl.R +import io.github.shadow578.yodel.R /** * file formats for track download diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapper.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapper.kt index b21fb4b..adf4402 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapper.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapper.kt @@ -4,11 +4,11 @@ import android.content.Context import android.util.Log import com.yausername.ffmpeg.FFmpeg import com.yausername.youtubedl_android.* -import io.github.shadow578.music_dl.BuildConfig +import io.github.shadow578.yodel.BuildConfig import java.io.File /** - * wrapper for [com.yausername.youtubedl_android.YoutubeDL]. + * wrapper for [YoutubeDL]. * all functions in this class should be run in a background thread only * * @param videoUrl the video url to download diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/InsertTrackUIHelper.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/InsertTrackUIHelper.kt index 6687c77..0ae55ca 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/ui/InsertTrackUIHelper.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/InsertTrackUIHelper.kt @@ -2,12 +2,10 @@ package io.github.shadow578.yodel.ui import android.content.Context import androidx.appcompat.app.AlertDialog -import io.github.shadow578.music_dl.R -import io.github.shadow578.music_dl.util.Async +import io.github.shadow578.yodel.R import io.github.shadow578.yodel.db.TracksDB import io.github.shadow578.yodel.db.model.TrackInfo -import io.github.shadow578.yodel.util.launchIO -import io.github.shadow578.yodel.util.launchMain +import io.github.shadow578.yodel.util.* /** * helper class for inserting tracks into the db from UI @@ -59,7 +57,7 @@ object InsertTrackUIHelper { .setTitle(R.string.tracks_replace_existing_title) .setMessage(ctx.getString(R.string.tracks_replace_existing_message, title)) .setPositiveButton(R.string.tracks_replace_existing_positive) { dialog, _ -> - Async.runAsync { + launchIO { insert( ctx, id, diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/base/BaseActivity.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/base/BaseActivity.kt index 24e6ca2..67c0cb0 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/ui/base/BaseActivity.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/base/BaseActivity.kt @@ -1,7 +1,6 @@ package io.github.shadow578.yodel.ui.base -import android.content.Context -import android.content.Intent +import android.content.* import android.net.Uri import android.os.Bundle import android.util.Log @@ -10,12 +9,10 @@ import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree import androidx.appcompat.app.AppCompatActivity import androidx.documentfile.provider.DocumentFile -import io.github.shadow578.music_dl.R +import io.github.shadow578.yodel.R import io.github.shadow578.yodel.downloader.DownloaderService import io.github.shadow578.yodel.util.preferences.Prefs -import io.github.shadow578.yodel.util.storage.StorageKey -import io.github.shadow578.yodel.util.storage.getPersistedFilePermission -import io.github.shadow578.yodel.util.storage.persistFilePermission +import io.github.shadow578.yodel.util.storage.* import io.github.shadow578.yodel.util.wrapLocale /** diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/main/MainActivity.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/main/MainActivity.kt index 8526620..3942a8d 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/main/MainActivity.kt @@ -2,13 +2,12 @@ package io.github.shadow578.yodel.ui.main import android.os.Bundle import androidx.annotation.IdRes -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.* import androidx.lifecycle.ViewModelProvider import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback -import io.github.shadow578.music_dl.R -import io.github.shadow578.music_dl.databinding.ActivityMainBinding +import io.github.shadow578.yodel.R +import io.github.shadow578.yodel.databinding.ActivityMainBinding import io.github.shadow578.yodel.ui.base.BaseActivity import io.github.shadow578.yodel.ui.more.MoreFragment import io.github.shadow578.yodel.ui.tracks.TracksFragment diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreFragment.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreFragment.kt index 9bf6d09..a0177bf 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreFragment.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreFragment.kt @@ -2,19 +2,15 @@ package io.github.shadow578.yodel.ui.more import android.net.Uri import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.Toast +import android.view.* +import android.widget.* import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.activity.result.contract.ActivityResultContracts.OpenDocument import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModelProvider -import io.github.shadow578.music_dl.R -import io.github.shadow578.music_dl.databinding.FragmentMoreBinding -import io.github.shadow578.yodel.LocaleOverride +import io.github.shadow578.yodel.* +import io.github.shadow578.yodel.databinding.FragmentMoreBinding import io.github.shadow578.yodel.downloader.TrackDownloadFormat import io.github.shadow578.yodel.ui.base.BaseFragment import java.util.* @@ -214,7 +210,7 @@ class MoreFragment : BaseFragment() { }) // always show all items - b.languageOverride.setOnClickListener { v -> + b.languageOverride.setOnClickListener { adapter.filter.filter(null) b.languageOverride.showDropDown() } diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreViewModel.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreViewModel.kt index cfa24c7..597122d 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreViewModel.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreViewModel.kt @@ -1,21 +1,16 @@ package io.github.shadow578.yodel.ui.more -import android.app.Activity -import android.app.Application +import android.app.* import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.documentfile.provider.DocumentFile -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.* import com.mikepenz.aboutlibraries.LibsBuilder -import io.github.shadow578.music_dl.R -import io.github.shadow578.music_dl.util.Async -import io.github.shadow578.yodel.LocaleOverride +import io.github.shadow578.yodel.* import io.github.shadow578.yodel.backup.BackupHelper import io.github.shadow578.yodel.downloader.TrackDownloadFormat import io.github.shadow578.yodel.ui.base.BaseActivity -import io.github.shadow578.yodel.util.launchIO -import io.github.shadow578.yodel.util.launchMain +import io.github.shadow578.yodel.util.* import io.github.shadow578.yodel.util.preferences.Prefs import java.util.concurrent.atomic.AtomicBoolean @@ -74,7 +69,7 @@ class MoreViewModel(application: Application) : AndroidViewModel(application) { // read the backup data val backup = BackupHelper.readBackupData(getApplication(), file) if (!backup.isPresent) { - Async.runOnMain { + launchMain { Toast.makeText( getApplication(), R.string.restore_toast_failed, diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/share/ShareTargetActivity.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/share/ShareTargetActivity.kt index 9a95057..dfd5bb1 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/ui/share/ShareTargetActivity.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/share/ShareTargetActivity.kt @@ -4,7 +4,7 @@ import android.content.Intent import android.os.Bundle import android.widget.Toast import androidx.appcompat.app.AppCompatActivity -import io.github.shadow578.music_dl.R +import io.github.shadow578.yodel.R import io.github.shadow578.yodel.downloader.DownloaderService import io.github.shadow578.yodel.ui.InsertTrackUIHelper import io.github.shadow578.yodel.util.extractTrackId diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/splash/SplashScreenActivity.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/splash/SplashScreenActivity.kt index cbe8ce6..e1a8ec7 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/ui/splash/SplashScreenActivity.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/splash/SplashScreenActivity.kt @@ -3,7 +3,7 @@ package io.github.shadow578.yodel.ui.splash import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity -import io.github.shadow578.music_dl.ui.main.MainActivity +import io.github.shadow578.yodel.ui.main.MainActivity import io.github.shadow578.yodel.util.launchMain import kotlinx.coroutines.delay diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksAdapter.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksAdapter.kt index e705718..2e6c19c 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksAdapter.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksAdapter.kt @@ -1,22 +1,20 @@ package io.github.shadow578.yodel.ui.tracks -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup +import android.view.* import androidx.annotation.DrawableRes import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide -import io.github.shadow578.music_dl.R -import io.github.shadow578.music_dl.databinding.RecyclerTrackViewBinding -import io.github.shadow578.yodel.db.model.TrackInfo -import io.github.shadow578.yodel.db.model.TrackStatus -import io.github.shadow578.yodel.util.launchMain -import io.github.shadow578.yodel.util.secondsToTimeString +import io.github.shadow578.yodel.R +import io.github.shadow578.yodel.databinding.RecyclerTrackViewBinding +import io.github.shadow578.yodel.db.model.* +import io.github.shadow578.yodel.util.* import io.github.shadow578.yodel.util.storage.decodeToUri import kotlinx.coroutines.delay import java.util.* +import kotlin.collections.List +import kotlin.collections.set /** * recyclerview adapter for tracks livedata diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksFragment.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksFragment.kt index ff9809f..d3788d2 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksFragment.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksFragment.kt @@ -3,24 +3,17 @@ package io.github.shadow578.yodel.ui.tracks import android.content.Intent import android.os.Bundle import android.util.TypedValue -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup +import android.view.* import android.widget.Toast -import androidx.annotation.AttrRes -import androidx.annotation.ColorInt +import androidx.annotation.* import androidx.lifecycle.ViewModelProvider -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import io.github.shadow578.music_dl.R -import io.github.shadow578.music_dl.databinding.FragmentTracksBinding +import androidx.recyclerview.widget.* +import io.github.shadow578.yodel.R +import io.github.shadow578.yodel.databinding.FragmentTracksBinding import io.github.shadow578.yodel.db.TracksDB -import io.github.shadow578.yodel.db.model.TrackInfo -import io.github.shadow578.yodel.db.model.TrackStatus +import io.github.shadow578.yodel.db.model.* import io.github.shadow578.yodel.ui.base.BaseFragment -import io.github.shadow578.yodel.util.SwipeToDeleteCallback -import io.github.shadow578.yodel.util.launchIO +import io.github.shadow578.yodel.util.* import io.github.shadow578.yodel.util.storage.decodeToUri /** diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/util/NotificationChannels.kt b/app/src/main/kotlin/io/github/shadow578/yodel/util/NotificationChannels.kt index 7a1af3d..d279d51 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/util/NotificationChannels.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/util/NotificationChannels.kt @@ -2,9 +2,8 @@ package io.github.shadow578.yodel.util import android.content.Context import androidx.annotation.StringRes -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationManagerCompat -import io.github.shadow578.music_dl.R +import androidx.core.app.* +import io.github.shadow578.yodel.R /** * class to handle notification channels diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 370409a..17827f9 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,5 +1,5 @@ - - \ No newline at end of file diff --git a/app/src/main/res/xml/backup_descriptor.xml b/app/src/main/res/xml/backup_descriptor.xml index 278c0c9..a217ba5 100644 --- a/app/src/main/res/xml/backup_descriptor.xml +++ b/app/src/main/res/xml/backup_descriptor.xml @@ -3,7 +3,7 @@ + path="io.github.shadow578.yodel_preferences.xml" /> Date: Wed, 4 Aug 2021 11:05:15 +0200 Subject: [PATCH 06/22] fix compile and runtime errors --- app/build.gradle | 5 +- .../7.json | 97 +++++++++++++++++++ .../shadow578/yodel/backup/BackupData.kt | 5 +- .../io/github/shadow578/yodel/db/TracksDB.kt | 13 +-- .../shadow578/yodel/db/model/TrackInfo.kt | 27 +++--- .../yodel/downloader/DownloadService.kt | 4 +- .../yodel/downloader/TrackMetadata.kt | 33 +++---- 7 files changed, 138 insertions(+), 46 deletions(-) create mode 100644 app/schemas/io.github.shadow578.yodel.db.TracksDB/7.json diff --git a/app/build.gradle b/app/build.gradle index 32d4a64..4aa6a5d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.application' id 'kotlin-android' + id 'kotlin-kapt' id 'com.mikepenz.aboutlibraries.plugin' } @@ -73,7 +74,7 @@ dependencies { // Room implementation "androidx.room:room-runtime:2.3.0" - annotationProcessor "androidx.room:room-compiler:2.3.0" + kapt "androidx.room:room-compiler:2.3.0" // youtube-dl implementation 'com.github.yausername.youtubedl-android:library:0.12.4' @@ -87,7 +88,7 @@ dependencies { // glide implementation 'com.github.bumptech.glide:glide:4.12.0' - annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0' + kapt 'com.github.bumptech.glide:compiler:4.12.0' // about libraries implementation 'com.mikepenz:aboutlibraries-core:8.9.1' diff --git a/app/schemas/io.github.shadow578.yodel.db.TracksDB/7.json b/app/schemas/io.github.shadow578.yodel.db.TracksDB/7.json new file mode 100644 index 0000000..faff383 --- /dev/null +++ b/app/schemas/io.github.shadow578.yodel.db.TracksDB/7.json @@ -0,0 +1,97 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "463c3a66039d05e55677fa8442ee57f7", + "entities": [ + { + "tableName": "tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `track_title` TEXT NOT NULL, `artist_name` TEXT, `release_date` TEXT, `duration` INTEGER, `album_name` TEXT, `audio_file_key` TEXT NOT NULL, `cover_file_key` TEXT NOT NULL, `status` TEXT NOT NULL, `first_added_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "track_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "releaseDate", + "columnName": "release_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "album_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "audioFileKey", + "columnName": "audio_file_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coverKey", + "columnName": "cover_file_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstAddedAt", + "columnName": "first_added_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_tracks_first_added_at", + "unique": false, + "columnNames": [ + "first_added_at" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_first_added_at` ON `${TABLE_NAME}` (`first_added_at`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '463c3a66039d05e55677fa8442ee57f7')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupData.kt b/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupData.kt index 241dcc4..afa4569 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupData.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupData.kt @@ -1,5 +1,6 @@ package io.github.shadow578.yodel.backup +import com.google.gson.annotations.SerializedName import io.github.shadow578.yodel.db.model.TrackInfo import java.time.LocalDateTime @@ -7,14 +8,16 @@ import java.time.LocalDateTime * backup data written by [BackupHelper] */ @Suppress("unused") -class BackupData( +data class BackupData( /** * the tracks in this backup */ + @SerializedName("tracks") val tracks: List, /** * the time the backup was created */ + @SerializedName("backup_time") val backupTime: LocalDateTime ) \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/db/TracksDB.kt b/app/src/main/kotlin/io/github/shadow578/yodel/db/TracksDB.kt index faf1bcf..34484c5 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/db/TracksDB.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/db/TracksDB.kt @@ -1,12 +1,8 @@ package io.github.shadow578.yodel.db import android.content.Context -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.room.TypeConverters -import io.github.shadow578.yodel.db.model.TrackInfo -import io.github.shadow578.yodel.db.model.TrackStatus +import androidx.room.* +import io.github.shadow578.yodel.db.model.* import io.github.shadow578.yodel.util.storage.decodeToFile /** @@ -64,7 +60,7 @@ abstract class TracksDB : RoomDatabase() { /** * database name */ - const val DB_NAME = "tracks" + private const val DB_NAME = "tracks" /** * the instance singleton @@ -82,7 +78,8 @@ abstract class TracksDB : RoomDatabase() { // have to initialize db instance = Room.databaseBuilder( ctx, - TracksDB::class.java, DB_NAME + TracksDB::class.java, + DB_NAME ) .fallbackToDestructiveMigration() .build() diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/db/model/TrackInfo.kt b/app/src/main/kotlin/io/github/shadow578/yodel/db/model/TrackInfo.kt index fe1d9ab..763481e 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/db/model/TrackInfo.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/db/model/TrackInfo.kt @@ -1,9 +1,6 @@ package io.github.shadow578.yodel.db.model -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.Index -import androidx.room.PrimaryKey +import androidx.room.* import io.github.shadow578.yodel.util.storage.StorageKey import java.time.LocalDate @@ -20,62 +17,62 @@ data class TrackInfo( /** * the youtube id of the track */ - @field:ColumnInfo(name = "id") - @field:PrimaryKey + @ColumnInfo(name = "id") + @PrimaryKey val id: String, /** * the title of the track */ - @field:ColumnInfo(name = "track_title") + @ColumnInfo(name = "track_title") var title: String, /** * the name of the artist */ - @field:ColumnInfo(name = "artist_name") + @ColumnInfo(name = "artist_name") var artist: String? = null, /** * the day the track was released / uploaded */ - @field:ColumnInfo(name = "release_date") + @ColumnInfo(name = "release_date") var releaseDate: LocalDate? = null, /** * duration of the track, in seconds */ - @field:ColumnInfo(name = "duration") + @ColumnInfo(name = "duration") var duration: Long? = null, /** * the album name, if this track is part of one */ - @field:ColumnInfo(name = "album_name") + @ColumnInfo(name = "album_name") var albumName: String? = null, /** * the key of the file this track was downloaded to */ - @field:ColumnInfo(name = "audio_file_key") + @ColumnInfo(name = "audio_file_key") var audioFileKey: StorageKey = StorageKey.EMPTY, /** * the key of the track cover image file */ - @field:ColumnInfo(name = "cover_file_key") + @ColumnInfo(name = "cover_file_key") var coverKey: StorageKey = StorageKey.EMPTY, /** * is this track fully downloaded? */ - @field:ColumnInfo(name = "status") + @ColumnInfo(name = "status") var status: TrackStatus = TrackStatus.DownloadPending, /** * when this track was first added. millis timestamp, from [System.currentTimeMillis] */ - @field:ColumnInfo(name = "first_added_at") + @ColumnInfo(name = "first_added_at") val firstAddedAt: Long = System.currentTimeMillis() ) { override fun equals(other: Any?): Boolean { diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt index 74d733f..86bd188 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt @@ -73,6 +73,7 @@ class DownloaderService : LifecycleService() { override fun onCreate() { super.onCreate() + notificationManager = NotificationManagerCompat.from(this) // ensure downloads are accessible if (!checkDownloadsDirSet()) { @@ -86,9 +87,6 @@ class DownloaderService : LifecycleService() { return } - // create progress notification - notificationManager = NotificationManagerCompat.from(this) - // init db and observe changes to pending tracks Log.i(TAG, "start observing pending tracks...") TracksDB.get(this).tracks().observePending().observe(this, diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackMetadata.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackMetadata.kt index 58cec35..bcc3633 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackMetadata.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackMetadata.kt @@ -2,8 +2,7 @@ package io.github.shadow578.yodel.downloader import com.google.gson.annotations.SerializedName import java.time.LocalDate -import java.time.format.DateTimeFormatter -import java.time.format.DateTimeParseException +import java.time.format.* /** * track metadata POJO. this is in the format that youtube-dl writes with the --write-info-json option @@ -13,96 +12,96 @@ data class TrackMetadata( /** * the full video title. for music videos, this often is in the format 'Artist - Song Title' */ - @field:SerializedName("title") + @SerializedName("title") val title: String? = null, /** * the alternative video title. for music videos, this often is just the 'Song Title' (in contrast to [.title]). * seems to be the same value as [.track] */ - @field:SerializedName("alt_title") + @SerializedName("alt_title") val alt_title: String? = null, /** * the upload date, in the format yyyyMMdd (that is without ANY spaces: 20200924 == 2020-09-24) */ - @field:SerializedName("upload_date") + @SerializedName("upload_date") val upload_date: String? = null, /** * the display name of the channel that uploaded the video */ - @field:SerializedName("channel") + @SerializedName("channel") val channel: String? = null, /** * the duration of the video, in seconds */ - @field:SerializedName("duration") + @SerializedName("duration") val duration: Long? = null, /** * the title of the track. this seems to be the same as [.alt_title] */ - @field:SerializedName("track") + @SerializedName("track") val track: String? = null, /** * the name of the actual song creator (not uploader channel). * This seems to be data from Content-ID */ - @field:SerializedName("creator") + @SerializedName("creator") val creator: String? = null, /** * the name of the actual song artist (not uploader channel). * This seems to be data from Content-ID */ - @field:SerializedName("artist") + @SerializedName("artist") val artist: String? = null, /** * the display name of the album this track is from. * only included for songs that are part of a album */ - @field:SerializedName("album") + @SerializedName("album") val album: String? = null, /** * categories of the video (like 'Music', 'Entertainment', 'Gaming' ...) */ - @field:SerializedName("categories") + @SerializedName("categories") val categories: List? = null, /** * tags on the video */ - @field:SerializedName("tags") + @SerializedName("tags") val tags: List? = null, /** * total view count of the video */ - @field:SerializedName("view_count") + @SerializedName("view_count") val view_count: Long? = null, /** * total likes on the video */ - @field:SerializedName("like_count") + @SerializedName("like_count") val like_count: Long? = null, /** * total dislikes on the video */ - @field:SerializedName("dislike_count") + @SerializedName("dislike_count") val dislike_count: Long? = null, /** * the average video like/dislike rating. * range seems to be 0-5 */ - @field:SerializedName("average_rating") + @SerializedName("average_rating") val average_rating: Double? = null ) { /** From b5eae8f871226e9481d64fdf1549716ec8246b29 Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Wed, 4 Aug 2021 11:11:34 +0200 Subject: [PATCH 07/22] fix failing tests --- .../github/shadow578/yodel/downloader/TrackMetadata.kt | 2 +- .../shadow578/yodel/downloader/TrackMetadataTest.kt | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackMetadata.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackMetadata.kt index bcc3633..ce42512 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackMetadata.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/TrackMetadata.kt @@ -137,7 +137,7 @@ data class TrackMetadata( } // fallback to channel - return channel + return if(channel.isNullOrBlank()) null else channel } /** diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/downloader/TrackMetadataTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/downloader/TrackMetadataTest.kt index 6b0fde5..cfb6c89 100644 --- a/app/src/test/kotlin/io/github/shadow578/yodel/downloader/TrackMetadataTest.kt +++ b/app/src/test/kotlin/io/github/shadow578/yodel/downloader/TrackMetadataTest.kt @@ -3,8 +3,7 @@ package io.github.shadow578.yodel.downloader import com.google.gson.Gson import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.* -import org.junit.Before -import org.junit.Test +import org.junit.* import java.time.LocalDate /** @@ -104,14 +103,14 @@ class TrackMetadataTest { ], "view_count": 128898492, "average_rating": 4.888588, - "upload_date": "foobar", - "channel": "Otseit", + "upload_date": "", + "channel": "", "duration": 192, "creator": "", "dislike_count": 34855, "artist": "", "album": "The Commerce", - "title": "Otseit - The Commerce (Official Music Video)", + "title": "", "alt_title": "", "categories": [ "Music" From 675d6eb50bfa7549799fc005c3c9dc851b2feca3 Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Wed, 4 Aug 2021 13:05:39 +0200 Subject: [PATCH 08/22] use kotlin buildscript --- app/build.gradle | 107 ------------------------------------------- app/build.gradle.kts | 107 +++++++++++++++++++++++++++++++++++++++++++ build.gradle | 30 ------------ build.gradle.kts | 28 +++++++++++ 4 files changed, 135 insertions(+), 137 deletions(-) delete mode 100644 app/build.gradle create mode 100644 app/build.gradle.kts delete mode 100644 build.gradle create mode 100644 build.gradle.kts diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 4aa6a5d..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,107 +0,0 @@ -plugins { - id 'com.android.application' - id 'kotlin-android' - id 'kotlin-kapt' - id 'com.mikepenz.aboutlibraries.plugin' -} - -android { - compileSdkVersion 30 - buildToolsVersion "30.0.3" - - defaultConfig { - applicationId "io.github.shadow578.yodel" - minSdkVersion 23 - targetSdkVersion 30 - compileSdkVersion 30 - versionCode 1 - versionName "1.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - - ndk { - abiFilters 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a' - } - javaCompileOptions { - annotationProcessorOptions { - arguments += ["room.schemaLocation": "$projectDir/schemas".toString()] - } - } - } - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - coreLibraryDesugaringEnabled true - //TODO reverted back to JDK 8 until the next version of AS is released in stable - // https://issuetracker.google.com/issues/180946610?pli=1 - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11 - } - buildFeatures { - viewBinding true - } - splits { - abi { - enable true - reset() - include 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a' - universalApk true - } - } -} -aboutLibraries{ - configPath = 'aboutlibraries' -} - -dependencies { - // androidX - implementation 'androidx.core:core-ktx:1.6.0' - implementation 'androidx.constraintlayout:constraintlayout:2.0.4' - implementation 'androidx.appcompat:appcompat:1.3.1' - implementation "androidx.lifecycle:lifecycle-service:2.3.1" - implementation 'androidx.legacy:legacy-support-v4:1.0.0' - implementation 'androidx.preference:preference-ktx:1.1.1' - - // material design - implementation 'com.google.android.material:material:1.4.0' - - // Room - implementation "androidx.room:room-runtime:2.3.0" - kapt "androidx.room:room-compiler:2.3.0" - - // youtube-dl - implementation 'com.github.yausername.youtubedl-android:library:0.12.4' - implementation 'com.github.yausername.youtubedl-android:ffmpeg:0.12.4' - - // id3v2 tagging - implementation 'com.mpatric:mp3agic:0.9.1' - - // gson - implementation 'com.google.code.gson:gson:2.8.7' - - // glide - implementation 'com.github.bumptech.glide:glide:4.12.0' - kapt 'com.github.bumptech.glide:compiler:4.12.0' - - // about libraries - implementation 'com.mikepenz:aboutlibraries-core:8.9.1' - implementation 'com.mikepenz:aboutlibraries:8.9.1' - - // desugaring - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' - - // unit testing - testImplementation 'junit:junit:4.13.2' - testImplementation 'org.hamcrest:hamcrest:2.2' - testImplementation 'com.github.npathai:hamcrest-optional:2.0.0' - androidTestImplementation 'androidx.test:runner:1.4.0' - androidTestImplementation 'androidx.test:rules:1.4.0' - androidTestImplementation 'com.github.npathai:hamcrest-optional:2.0.0' -} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..355c2db --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,107 @@ +plugins { + id("com.android.application") + id("kotlin-android") + id("kotlin-kapt") + id("com.mikepenz.aboutlibraries.plugin") +} + +android { + compileSdk = 30 + buildToolsVersion = "30.0.3" + + defaultConfig { + applicationId = "io.github.shadow578.yodel" + minSdk = 23 + targetSdk = 30 + compileSdk = 30 + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + ndk { + abiFilters += setOf("x86", "x86_64", "armeabi-v7a", "arm64-v8a") + } + kapt { + arguments { + arg("room.schemaLocation", "$projectDir/schemas") + } + } + } + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + + //TODO reverted back to JDK 8 until the next version of AS is released in stable + // https://issuetracker.google.com/issues/180946610?pli=1 + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + buildFeatures { + viewBinding = true + } + splits { + abi { + isEnable = true + reset() + include("x86", "x86_64", "armeabi-v7a", "arm64-v8a") + isUniversalApk = true + } + } +} +aboutLibraries{ + configPath = "aboutlibraries" +} + +dependencies { + // androidX + implementation("androidx.core:core-ktx:1.6.0") + implementation("androidx.constraintlayout:constraintlayout:2.0.4") + implementation("androidx.appcompat:appcompat:1.3.1") + implementation("androidx.lifecycle:lifecycle-service:2.3.1") + implementation("androidx.legacy:legacy-support-v4:1.0.0") + implementation("androidx.preference:preference-ktx:1.1.1") + + // material design + implementation("com.google.android.material:material:1.4.0") + + // Room + implementation("androidx.room:room-runtime:2.3.0") + kapt("androidx.room:room-compiler:2.3.0") + + // youtube-dl + implementation("com.github.yausername.youtubedl-android:library:0.12.4") + implementation("com.github.yausername.youtubedl-android:ffmpeg:0.12.4") + + // id3v2 tagging + implementation("com.mpatric:mp3agic:0.9.1") + + // gson + implementation("com.google.code.gson:gson:2.8.7") + + // glide + implementation("com.github.bumptech.glide:glide:4.12.0") + kapt("com.github.bumptech.glide:compiler:4.12.0") + + // about libraries + implementation("com.mikepenz:aboutlibraries-core:8.9.1") + implementation("com.mikepenz:aboutlibraries:8.9.1") + + // desugaring + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5") + + // unit testing + testImplementation("junit:junit:4.13.2") + testImplementation("org.hamcrest:hamcrest:2.2") + testImplementation("com.github.npathai:hamcrest-optional:2.0.0") + androidTestImplementation("androidx.test:runner:1.4.0") + androidTestImplementation("androidx.test:rules:1.4.0") + androidTestImplementation("com.github.npathai:hamcrest-optional:2.0.0") +} diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 5ed5ca3..0000000 --- a/build.gradle +++ /dev/null @@ -1,30 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -buildscript { - repositories { - google() - mavenCentral() - maven { - url "https://plugins.gradle.org/m2/" - } - } - dependencies { - classpath 'com.android.tools.build:gradle:7.0.0' - classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.21' - classpath 'com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:8.9.1' - - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files - } -} - -allprojects { - repositories { - google() - mavenCentral() - maven { url 'https://jitpack.io' } - } -} - -task clean(type: Delete) { - delete rootProject.buildDir -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..87b4fb0 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,28 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + repositories { + google() + mavenCentral() + maven { setUrl("https://plugins.gradle.org/m2/") } + } + dependencies { + classpath("com.android.tools.build:gradle:7.0.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.21") + classpath("com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:8.9.1") + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle.kts files + } +} + +allprojects { + repositories { + google() + mavenCentral() + maven { setUrl("https://jitpack.io") } + } +} + +tasks.register("clean", Delete::class) { + delete(rootProject.buildDir) +} From b1f3f360d17280f1185c8871f8b613e36f27937a Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Wed, 4 Aug 2021 14:50:32 +0200 Subject: [PATCH 09/22] migrate tests to kotest --- app/build.gradle.kts | 16 ++- .../shadow578/yodel/util/UtilAndroidTest.kt | 21 ++-- .../util/storage/StorageHelperAndroidTest.kt | 53 ++++----- .../yodel/downloader/TrackMetadataTest.kt | 73 ++++++------ .../wrapper/YoutubeDLWrapperTest.kt | 60 ++++++---- .../github/shadow578/yodel/util/UtilTest.kt | 112 +++++++++--------- 6 files changed, 165 insertions(+), 170 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 355c2db..b8786c9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,7 +30,10 @@ android { buildTypes { release { isMinifyEnabled = false - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } } compileOptions { @@ -55,8 +58,12 @@ android { isUniversalApk = true } } + packagingOptions.resources { + excludes.add("META-INF/LICENSE.md") + excludes.add("META-INF/LICENSE-notice.md") + } } -aboutLibraries{ +aboutLibraries { configPath = "aboutlibraries" } @@ -99,9 +106,8 @@ dependencies { // unit testing testImplementation("junit:junit:4.13.2") - testImplementation("org.hamcrest:hamcrest:2.2") - testImplementation("com.github.npathai:hamcrest-optional:2.0.0") + testImplementation("io.kotest:kotest-assertions-core:4.6.1") androidTestImplementation("androidx.test:runner:1.4.0") androidTestImplementation("androidx.test:rules:1.4.0") - androidTestImplementation("com.github.npathai:hamcrest-optional:2.0.0") + androidTestImplementation("io.kotest:kotest-assertions-core:4.6.1") } diff --git a/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/UtilAndroidTest.kt b/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/UtilAndroidTest.kt index 11b11fd..c9042cd 100644 --- a/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/UtilAndroidTest.kt +++ b/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/UtilAndroidTest.kt @@ -3,9 +3,9 @@ package io.github.shadow578.yodel.util import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry import com.bumptech.glide.util.Util -import org.hamcrest.MatcherAssert -import org.hamcrest.core.Is -import org.hamcrest.core.IsNull +import io.kotest.assertions.withClue +import io.kotest.matchers.file.* +import io.kotest.matchers.nulls.shouldNotBeNull import org.junit.Test import java.io.File @@ -19,12 +19,13 @@ class UtilAndroidTest { */ @Test fun shouldGetTempFile() { - val temp: File = - InstrumentationRegistry.getInstrumentation().targetContext.cacheDir.getTempFile( - "foo", - "bar" - ) - MatcherAssert.assertThat(temp, IsNull.notNullValue()) - MatcherAssert.assertThat(temp.exists(), Is.`is`(false)) + withClue("getTempFile() should return a file in the parent directory that does not exist") { + val cache = InstrumentationRegistry.getInstrumentation().targetContext.cacheDir + val temp: File = cache.getTempFile("foo", "bar") + + temp.shouldNotBeNull() + temp.shouldNotExist() + temp.shouldStartWithPath(cache) + } } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/storage/StorageHelperAndroidTest.kt b/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/storage/StorageHelperAndroidTest.kt index 86a9093..d47fc02 100644 --- a/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/storage/StorageHelperAndroidTest.kt +++ b/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/storage/StorageHelperAndroidTest.kt @@ -4,10 +4,8 @@ import android.net.Uri import androidx.documentfile.provider.DocumentFile import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.core.IsEqual.equalTo -import org.hamcrest.core.IsNot.not -import org.hamcrest.core.IsNull +import io.kotest.matchers.* +import io.kotest.matchers.nulls.* import org.junit.Test import java.io.File @@ -16,31 +14,28 @@ import java.io.File */ @SmallTest class StorageHelperAndroidTest { + private fun beNonEmptyStorageKey() = object : Matcher { + override fun test(value: StorageKey): MatcherResult = MatcherResult( + value.key.isNotBlank(), + "$value should be a non-empty storage key", + "$value should be a empty storage key" + ) + } + /** * [decodeToFile] and [decodeToUri] */ @Test fun shouldEncodeAndDecodeUri() { - val uri = Uri.fromFile( - File( - InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, - "test.bar" - ) - ) + val cache = InstrumentationRegistry.getInstrumentation().targetContext.cacheDir + val uri = Uri.fromFile(File(cache, "foo.bar")) // encode val key: StorageKey = uri.encodeToKey() - assertThat( - key, IsNull.notNullValue( - StorageKey::class.java - ) - ) + key should beNonEmptyStorageKey() // decode - assertThat( - key.decodeToUri(), - equalTo(uri) - ) + key.decodeToUri() shouldBe uri } /** @@ -48,7 +43,7 @@ class StorageHelperAndroidTest { */ @Test fun shouldNotDecodeUri() { - assertThat(StorageKey.EMPTY.decodeToUri(), equalTo(null)) + StorageKey.EMPTY.decodeToUri().shouldBeNull() } /** @@ -57,24 +52,20 @@ class StorageHelperAndroidTest { @Test fun shouldEncodeAndDecodeFile() { val ctx = InstrumentationRegistry.getInstrumentation().targetContext - val uri = Uri.fromFile(File(ctx.cacheDir, "test.bar")) + val uri = Uri.fromFile(File(ctx.cacheDir, "foo.bar")) val file = DocumentFile.fromSingleUri(ctx, uri) // check test setup - assertThat(file, IsNull.notNullValue()) + file.shouldNotBeNull() // encode - val key: StorageKey = file!!.encodeToKey() - assertThat( - key, IsNull.notNullValue( - StorageKey::class.java - ) - ) + val key: StorageKey = file.encodeToKey() + key should beNonEmptyStorageKey() // decode val decodedFile: DocumentFile? = key.decodeToFile(ctx) - assertThat(decodedFile, not(equalTo(null))) - assertThat(decodedFile!!.uri, equalTo(file.uri)) + decodedFile.shouldNotBeNull() + decodedFile.uri shouldBe file.uri } /** @@ -85,6 +76,6 @@ class StorageHelperAndroidTest { val ctx = InstrumentationRegistry.getInstrumentation().targetContext //empty key - assertThat(StorageKey.EMPTY.decodeToFile(ctx), equalTo(null)) + StorageKey.EMPTY.decodeToFile(ctx).shouldBeNull() } } \ No newline at end of file diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/downloader/TrackMetadataTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/downloader/TrackMetadataTest.kt index cfb6c89..21a11e1 100644 --- a/app/src/test/kotlin/io/github/shadow578/yodel/downloader/TrackMetadataTest.kt +++ b/app/src/test/kotlin/io/github/shadow578/yodel/downloader/TrackMetadataTest.kt @@ -1,8 +1,9 @@ package io.github.shadow578.yodel.downloader import com.google.gson.Gson -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.* +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.nulls.* +import io.kotest.matchers.shouldBe import org.junit.* import java.time.LocalDate @@ -15,7 +16,6 @@ class TrackMetadataTest { private lateinit var metaNoAltTitleCreatorBadDate: TrackMetadata private lateinit var metaNoTitleChannelDate: TrackMetadata - /** * deserialize a mock metadata json object */ @@ -135,28 +135,24 @@ class TrackMetadataTest { @Test fun shouldDeserialize() { // check fields are correct - assertThat(metaFull, notNullValue(TrackMetadata::class.java)) - assertThat(metaFull.track, equalTo("The Commerce")) - assertThat(metaFull.tags, containsInAnyOrder("Otseit", "The Commerce")) - assertThat(metaFull.view_count, equalTo(128898492L)) - assertThat(metaFull.average_rating, equalTo(4.888588)) - assertThat(metaFull.upload_date, equalTo("20200924")) - assertThat(metaFull.channel, equalTo("Otseit")) - assertThat(metaFull.duration, equalTo(192L)) - assertThat(metaFull.creator, equalTo("Otseit")) - assertThat(metaFull.dislike_count, equalTo(34855L)) - assertThat(metaFull.artist, equalTo("Otseit,AWatson")) - assertThat(metaFull.album, equalTo("The Commerce")) - assertThat( - metaFull.title, - equalTo("Otseit - The Commerce (Official Music Video)") - ) - assertThat(metaFull.alt_title, equalTo("Otseit - The Commerce")) - assertThat?>( - metaFull.categories, - containsInAnyOrder("Music") - ) - assertThat(metaFull.like_count, equalTo(1216535L)) + metaFull.shouldNotBeNull() + with(metaFull) { + track shouldBe "The Commerce" + tags.shouldContainExactlyInAnyOrder("Otseit", "The Commerce") + view_count shouldBe 128898492 + average_rating shouldBe 4.888588 + upload_date shouldBe "20200924" + channel shouldBe "Otseit" + duration shouldBe 192 + creator shouldBe "Otseit" + dislike_count shouldBe 34855 + artist shouldBe "Otseit,AWatson" + album shouldBe "The Commerce" + title shouldBe "Otseit - The Commerce (Official Music Video)" + alt_title shouldBe "Otseit - The Commerce" + categories.shouldContainExactlyInAnyOrder("Music") + like_count shouldBe 1216535 + } } /** @@ -164,13 +160,10 @@ class TrackMetadataTest { */ @Test fun shouldGetTitle() { - assertThat(metaFull.getTrackTitle(), equalTo("The Commerce")) - assertThat(metaNoTrackArtistDate.getTrackTitle(), equalTo("Otseit - The Commerce")) - assertThat( - metaNoAltTitleCreatorBadDate.getTrackTitle(), - equalTo("Otseit - The Commerce (Official Music Video)") - ) - assertThat(metaNoTitleChannelDate.getTrackTitle(), nullValue()) + metaFull.getTrackTitle() shouldBe "The Commerce" + metaNoTrackArtistDate.getTrackTitle() shouldBe "Otseit - The Commerce" + metaNoAltTitleCreatorBadDate.getTrackTitle() shouldBe "Otseit - The Commerce (Official Music Video)" + metaNoTitleChannelDate.getTrackTitle().shouldBeNull() } /** @@ -178,10 +171,10 @@ class TrackMetadataTest { */ @Test fun shouldGetArtistName() { - assertThat(metaFull.getArtistName(), equalTo("Otseit")) - assertThat(metaNoTrackArtistDate.getArtistName(), equalTo("Otseit")) - assertThat(metaNoAltTitleCreatorBadDate.getArtistName(), equalTo("Otseit")) - assertThat(metaNoTitleChannelDate.getArtistName(), nullValue()) + metaFull.getArtistName() shouldBe "Otseit" + metaNoTrackArtistDate.getArtistName() shouldBe "Otseit" + metaNoAltTitleCreatorBadDate.getArtistName() shouldBe "Otseit" + metaNoTitleChannelDate.getArtistName().shouldBeNull() } /** @@ -189,8 +182,8 @@ class TrackMetadataTest { */ @Test fun shouldGetUploadDate() { - assertThat(metaFull.getUploadDate(), equalTo(LocalDate.of(2020, 9, 24))) - assertThat(metaNoTrackArtistDate.getUploadDate(), nullValue()) - assertThat(metaNoAltTitleCreatorBadDate.getUploadDate(), nullValue()) + metaFull.getUploadDate() shouldBe LocalDate.of(2020, 9, 24) + metaNoTrackArtistDate.getUploadDate().shouldBeNull() + metaNoAltTitleCreatorBadDate.getUploadDate().shouldBeNull() } -} +} \ No newline at end of file diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapperTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapperTest.kt index 71cea47..20e1810 100644 --- a/app/src/test/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapperTest.kt +++ b/app/src/test/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapperTest.kt @@ -1,8 +1,8 @@ package io.github.shadow578.yodel.downloader.wrapper import com.yausername.youtubedl_android.YoutubeDLRequest -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.* +import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.nulls.shouldNotBeNull import org.junit.Test import java.io.File @@ -30,38 +30,46 @@ class YoutubeDLWrapperTest { // check internal request has correct parameters val request: YoutubeDLRequest = session.request - assertThat(request, notNullValue(YoutubeDLRequest::class.java)) - var args = request.buildCommand() - assertThat(args, hasItem("--no-continue")) - assertThat( - args, - hasItems("--no-check-certificate", "--prefer-insecure") - ) - assertThat(args, hasItems("-f", "best")) - assertThat(args, hasItem("--write-info-json")) - assertThat(args, hasItem("--write-thumbnail")) - assertThat(args, hasItems("-o", targetFile.absolutePath)) - assertThat(args, hasItems("--cache-dir", cacheDir.absolutePath)) - assertThat(args, hasItem(videoUrl)) + request.shouldNotBeNull() + + // technically, this could be done with only one shouldContainAll + // but this makes it more structured (every shouldContainAll == a parameter with (optional) value) + with(request.buildCommand()) + { + shouldContainAll("--no-continue") + shouldContainAll("--no-check-certificate", "--prefer-insecure") + shouldContainAll("-f", "best") + shouldContainAll("--write-info-json") + shouldContainAll("--write-thumbnail") + shouldContainAll("-o", targetFile.absolutePath) + shouldContainAll("--cache-dir", cacheDir.absolutePath) + shouldContainAll(videoUrl) + } // audio only session.audioOnly("mp3") - args = session.request.buildCommand() - assertThat(args, hasItems("-f", "bestaudio")) - assertThat(args, hasItems("--extract-audio")) - assertThat(args, hasItems("--audio-quality", "0")) - assertThat(args, hasItems("--audio-format", "mp3")) - + with(session.request.buildCommand()) + { + shouldContainAll("-f", "bestaudio") + shouldContainAll("--extract-audio") + shouldContainAll("--audio-quality", "0") + shouldContainAll("--audio-format", "mp3") + } // video only session.videoOnly() - args = session.request.buildCommand() - assertThat(args, hasItems("-f", "bestvideo")) + with(session.request.buildCommand()) + { + shouldContainAll("-f", "bestvideo") + } // custom option session.setOption("--foo", "bar") .setOption("--yee", null) - args = session.request.buildCommand() - assertThat(args, hasItems("--foo", "bar", "--yee")) + with(session.request.buildCommand()) + { + shouldContainAll("--foo", "bar") + shouldContainAll("--yee") + } } -} \ No newline at end of file +} diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilTest.kt index bf6ec36..e265534 100644 --- a/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilTest.kt +++ b/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilTest.kt @@ -1,10 +1,10 @@ package io.github.shadow578.yodel.util import com.bumptech.glide.util.Util -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers -import org.hamcrest.core.IsEqual.equalTo -import org.hamcrest.text.IsEqualIgnoringCase.equalToIgnoringCase +import io.kotest.assertions.withClue +import io.kotest.matchers.nulls.* +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.* import org.junit.Test /** @@ -16,66 +16,59 @@ class UtilTest { */ @Test fun shouldExtractVideoId() { - // youtube full - assertThat( - extractTrackId("https://www.youtube.com/watch?v=6Xs26b4RSu4"), - equalToIgnoringCase("6Xs26b4RSu4") - ) + withClue("extractTrackId() for full youtube URL") + { + extractTrackId("https://www.youtube.com/watch?v=6Xs26b4RSu4") shouldBe "6Xs26b4RSu4" + } - // youtube short (https) - assertThat( - extractTrackId("https://youtu.be/6Xs26b4RSu4"), - equalToIgnoringCase("6Xs26b4RSu4") - ) + withClue("extractTrackId() for short youtube URL (https)") + { + extractTrackId("https://youtu.be/6Xs26b4RSu4") shouldBe "6Xs26b4RSu4" + } - // youtube short (http) - assertThat( - extractTrackId("http://youtu.be/6Xs26b4RSu4"), - equalToIgnoringCase("6Xs26b4RSu4") - ) + withClue("extractTrackId() for short youtube URL (http)") + { + extractTrackId("http://youtu.be/6Xs26b4RSu4") shouldBe "6Xs26b4RSu4" + } - // youtube short (no protocol) - assertThat( - extractTrackId("youtu.be/6Xs26b4RSu4"), - equalToIgnoringCase("6Xs26b4RSu4") - ) + withClue("extractTrackId() for short youtube URL (no protocol)") + { + extractTrackId("youtu.be/6Xs26b4RSu4") shouldBe "6Xs26b4RSu4" + } - // youtube full with playlist - assertThat( - extractTrackId("https://www.youtube.com/watch?v=6Xs26b4RSu4&list=RD6Xs26b4RSu4&start_radio=1&rv=6Xs26b4RSu4&t=0"), - equalToIgnoringCase("6Xs26b4RSu4") - ) + withClue("extractTrackId() for youtube URL with playlist") + { + extractTrackId("https://www.youtube.com/watch?v=6Xs26b4RSu4&list=RD6Xs26b4RSu4&start_radio=1&rv=6Xs26b4RSu4&t=0") shouldBe "6Xs26b4RSu4" + } + withClue("extractTrackId() for youtube music URL") + { + extractTrackId("https://music.youtube.com/watch?v=wbJwhx29O5U&list=RDAMVMwbJwhx29O5U") shouldBe "wbJwhx29O5U" + } - // youtube music - assertThat( - extractTrackId("https://music.youtube.com/watch?v=wbJwhx29O5U&list=RDAMVMwbJwhx29O5U"), - equalToIgnoringCase("wbJwhx29O5U") - ) + withClue("extractTrackId() for URL from share dialog of youtube music app") + { + extractTrackId("https://music.youtube.com/watch?v=wbJwhx29O5U&feature=share") shouldBe "wbJwhx29O5U" + } - // youtube music by share dialog - assertThat( - extractTrackId("https://music.youtube.com/watch?v=wbJwhx29O5U&feature=share"), - equalToIgnoringCase("wbJwhx29O5U") - ) - - // invalid link - assertThat(extractTrackId("foobar"), equalTo(null)) + withClue("extractTrackId() for invalid URL") + { + extractTrackId("foobar").shouldBeNull() + } } /** - * [Util.generateRandomAlphaNumeric] + * [generateRandomAlphaNumeric] */ @Test fun shouldGenerateRandomString() { - val random: String = generateRandomAlphaNumeric(128) - assertThat( - random, Matchers.notNullValue( - String::class.java - ) - ) - assertThat(random.length, Matchers.`is`(128)) - assertThat(random.isEmpty(), Matchers.`is`(false)) + withClue("generateRandomAlphaNumeric(128) generates a string with 128 chars") + { + val random = generateRandomAlphaNumeric(128) + random.shouldNotBeNull() + random shouldHaveLength 128 + random.shouldNotBeBlank() + } } /** @@ -83,14 +76,17 @@ class UtilTest { */ @Test fun shouldConvertSecondsToString() { - // < 1h - assertThat(620.secondsToTimeString(), Matchers.equalTo("10:20")) - assertThat(520.secondsToTimeString(), Matchers.equalTo("8:40")) + withClue("secondsToTimeString() should give correct results") + { + // < 1h + 620.secondsToTimeString() shouldBe "10:20" + 520.secondsToTimeString() shouldBe "8:40" - // > 1h - assertThat(7300.secondsToTimeString(), Matchers.equalTo("2:01:40")) + // > 1h + 7300.secondsToTimeString() shouldBe "2:01:40" - // > 10d - assertThat(172800.secondsToTimeString(), Matchers.equalTo("48:00:00")) + // > 10h + 172800.secondsToTimeString() shouldBe "48:00:00" + } } } \ No newline at end of file From 9a6dc43c6a0a9c993f335ba03fa38f83cfeed6a3 Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Wed, 4 Aug 2021 15:11:35 +0200 Subject: [PATCH 10/22] update README --- .github/res/app_icon_smol.png | Bin 0 -> 1098 bytes .github/res/ic_launcher_round.png | Bin 3033 -> 0 bytes CONTRIBUTING.md | 34 ++++++++++++++++++------------ README.md | 13 ++++++------ 4 files changed, 27 insertions(+), 20 deletions(-) create mode 100644 .github/res/app_icon_smol.png delete mode 100644 .github/res/ic_launcher_round.png diff --git a/.github/res/app_icon_smol.png b/.github/res/app_icon_smol.png new file mode 100644 index 0000000000000000000000000000000000000000..e38e2075063298cfe2f54bba3af65a188e59d0d2 GIT binary patch literal 1098 zcmV-Q1hxB#P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGxhX4Q_hXIe}@nrx21K&wRK~zXfjaExY zTR{{ZpNXl_g4AF9Vbq0+zk-T3E%@75T)7dXir_*Nm(_)}3qesq{DP%Osvs0x=u+yB zsvun{XhG3x{B497|DU8LdVBBW<@pky)eFa&d2{ZWJ9F<$^kUC201$i3M{IBCkC)fe z?vFA=(J*X3VEZ1Civ3sg5rvPj`HlF2{qNWwJZCYW2Kli_5ii$u7%xPYRfA}Q+P;$k>GJ%yyCBse-c;v70Qy#`wOY+vxi509Yiny^T$~J#=cAlJ zh>3{-l}ZI&U0r;PK|p+bJnZl9^YO^Y2qY#Z@*V8#?6~<7aF_L5yt})b7MPJwk{F%$ z#KeU3t*564%FD}ne`{+CbUGbWS66emIC7kypZ{!XYGMq_PN3||CvhyDGvVy)j0>); ztiaXP6=Y;&Ky!05L`6jb=1=0ufjmn}N{S$qm6fUS7V;bFGP;!k0RiHOv4MdBmYto= zl9Q8JZEYR=^Y-=*$jQmku=@J?7r3IsGXfVVxs;Fj`FUt; zY=nr22r!vU5E>c^)6>(6J_M{P2L`WY!NI{Q%)x`p<-+LbD6gcDkPu#u3kwU-*VhMe zadCitJOHei2Yy)QmY|>@m4Ib-hon{K#vYo{XymQMVqt@WgDfj6i{Szc6VA+LGb<=4 zU^6o_oFg|ZlvUE^gM)*EPo5FDU@#Z}a{~SS{d_z*ImyRkV`C5<9Syhu--V~&`uh53 zE>1QyG!#RZ!;?hV-rk0(sVQlU=gN=D-`^k7)6?Pj_}Gn4!02slZEg42M+fiS-Q6#q zL{HHDAYpXjIl!GO@-Qb~Nz+M(ozpz>? z_`cW))TeJWM;zjcZV&7^{|cz_UC|2Kn6#xJL literal 0 HcmV?d00001 diff --git a/.github/res/ic_launcher_round.png b/.github/res/ic_launcher_round.png deleted file mode 100644 index 6dde11ed3a2c968bb444ffccaaa6b54d34870622..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3033 zcmbVO2~-o;8Xgo8kwsA}h(d@|MNBdYA&@{=g90@y0Ra`%Fqy!-kW9=32owRexYXhT zVg(gJ5Bktn3RZ9_f<@#2Do>G3Dxy53B7$fIDFW|??bvs$?dhB|nVEaP`~Tnn{maQ- zU+*<~I*W852-5TP;0eIfNPTN-f#0?m?mO_9CHGiQK+x=Y>RSWKJPr^u+OdFOWiWp& z2f?LOp$HE{sVb=)pdrY?NhKE|5>!bFL&Gr{m)v`;luW`zTyl^NALh&5&+?9g>3iz9ctX zfs$BMn1aACgJjF5(pgNVo!u%D17^T9*p^0zDGUY&W^rt+NfQ?tcvFbP9070jgfH;L zB}XWgat@6a6B9#?v8LjRa2lP>X47B>jlrM*1ciu|DTOMEj95Bpfrk=^0+TB-Tt-q` z6o%nZN-h~N9S=b&pE4^WCS(GF(Nsb?jZTHtA&mh=$P`WQ5Y#;y z&|IpHM$U0lph6|C2*B~k$wc{1QYJANRG73P2$PBM7{c;{11L|ZM7d;;H97^hrC7rO zbUO~ymSe-Vfy}y6lRK;2nC_r&?$C8rVT}iu*4KQ2HV!w&IYElnZVIl#yC72iBgxq z)!2#F@kh5#yhUQ9v2x)E5p^4I$%s1tC}1$YjD19bzZb#y`Irb482v%1oaQ=^q)|lkQIp#%ED1FQ)y&%Ff{e_8BYrB z|MNAr_rau^goE*?ew~`T;A3iXqcUJx0p{`#K8idr?vH!&Tmn4yrUZtr4QN>~(x0(& z4>7Bx%!T=VPKbo6NcUXZ_GAOnWk7S(c4ZpU&MZ6HRy%$5Nw129efcz={W8a+MyFeE z-oz?r%}YGB?3*U-WUEUR=RFqp{F~i=dr7kKaqIBjd;`=wT$_J2HSPF`l7@lrFZFMb z;4X%vyCW=WG~(&SS#w?Eq3mZ?sXX$bvZodM{DOnEx0%l{=k<>Ux$fzOA}t0b{JI4C z*&`(OtTyu`E7v`Fqt91V{p4dgBNg?^yC|qOw){Nz@NF;7iD%v2t2`Ale6?3>pyfL8 z`p*P;^TP^^#NpxQxwn@e6u5c~npk!mmA3pSJ?3SZDk2|h%kw`Qym4dA%a-Cdb!UC9 zSXc}mxE9lUp{uLQ)xmV(bLFBV=#9QO{r3{fT&yKJt1+SUG<{DY5jL2!$Y2_%e{}cT?oCl_{6rM z{<)Anhx1y1W{dQm=-^|Qxw4p=6&b6o0x2L8WhLk2OnW?`Rf{wEZ zEm^TiN=k~6vGMlC7w&w%US?*dZ%|NY)5_dzY|+>I_LcINrIpsF3mzW~$!IaDudi4A z_Uqguw9WdEYVh^vcTd>w_FbU~MM$L;yYx(6J-cJVw6S5AT@eHZ78Ra5cP{YYPg>c2 zmg_PfJz!FN2LF@eb~H0{M{TDYf-J4BsqtI4E*Ypwp06`pY(aGU=RK%B>6Cc=^zt-M zKVRP@_kEwSH{Fi14xugt#qd7Ci5hY_DJ#qLo0JrxN@YZ$P?`#mnUJ&gp*0XxB%NJT zsQLTQP_0wqNIbdNil}+}x})Yn)QR-;^i?ZYKHayF*3{To`SZ`N8;-cwF^zSSk5-9Z z>&J;iqSw#v97}A_($X5Sp@S=NHCm_ad+Od)lrqr!xV@|XeDI}zE$}|+sF&Y3JG9X+ zC}?KsV}sDzlgIKmmBevj7{1_6KD0fiw^fgN_)EwmR{$klzwXGiwJq;_V{W*&>}7O$ zdtVu>tBi^1TsH>-4RfOyf2_ZyrAQiq#k z$TvdlQ+0Qj_cb&$*c@X?cbs{_2`gxN<4lqx3G=-yzuQ=>zp*G%1LB?b)7MK?3#|JY0G^) zQoZsVPE;N+8x7vPx%5;{js_^?-Jlb{n)B|}lX9OWOP0KRB3Esw`*B9?(mC1?+~`eC`9!=vWr!B7WN14*V%BZN0?bFLZ=bnA|@L@vu`i8Xo_XXPKq_2`B&4p<5EoTg~G~9c$Mbda-P`CWu zslvj-yDy_9&GvEaw^AZ7Y^!tfoIR^g-|rpR+|%9tzC_wG%G8~H1U0vBSv)OwzK)L0 z527l=%OMvulS=#h`$sC6cbYRYGIodbNwdO_JGCByCMRzau0 p%1}sXs9rgo{UWY)pM}+^mg~$=$)#TBi|Rk$p6=ef3$9ys{14uvuK@r6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59dc294..4421154 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,8 +4,10 @@ Looking to report a Bug or make a feature request? Take a look [here](https://gi ### Thank you for your interest in contributing to Yodel! +
+ # Translations -Translations are currently only possible by directly editing the strings.xml file. +Translations are currently only possible by directly editing the strings.xml file and creating a PR. # Code Contributions @@ -21,28 +23,32 @@ Forks are allowed so long they abide by [Yodel's LICENSE](LICENSE) When creating a fork, remember to: -- Avoid confusion with the main app and conflicts by: - - Changing the app name (strings/app_name) - - Changing the app icon - - Change the 'applicationId' in build.gradle - +- Avoid confusion with the main app by: + - Changing the app name (strings/app_name) + - Changing the app icon +- Avoid installation conflicts by: + - Change the 'applicationId' in build.gradle + + # Code Style Guidelines These are the guidelines you should follow when contributing code to Yodel.
These Guidelines outline what I think are useful rules to create readable and manageable code, tho they are always open for discussion(i'm not a professional developer after all, so what do i know :P) -- Yodel uses the [Model View ViewModel](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel) pattern. And you should too -- Please use Java (I'm not familiar with Kotlin (yet), and don't have the time to learn it because of University) + +- Yodel uses the [Model View ViewModel](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel) pattern +- Use Kotlin as the main development language + - I'm new to kotlin, so if i'm doing something stupid (or could do it better), please let me know + - if you use a language concept that is not obvious, please leave a comment - Do not hardcode stuff (intent extras/actions, urls, ...), but use constants in the appropriate classes instead -- Enums in PascalCase (yes, java convention is to use ALL_UPPERCASE, but that looks ridiculous) -- include javadoc comments for: +- Enums in PascalCase (convention seems to be ALL_UPPERCASE, but that looks ridiculous) +- include kdoc comments for: - __all__ classes, interfaces and enums - __all__ public fields, methods and constants - _optionally_ private fields, methods and constants - comments should describe the class/field/method, but don't have to be too long -- Use @Nullable / @NonNull annotations on: - - __all__ parameters and return values of public methods - - _optionally_ private methods -- Use Lambda expressions where java allows it - do __not__ ignore lint warnings +- Try to include tests for your contribution (where applicable) + - Yodel uses [Kotest](https://kotest.io/) with JUnit for unit and instrumented tests + diff --git a/README.md b/README.md index f8e825d..e7774e0 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,21 @@ -# ![App Icon](.github/res/ic_launcher_round.png) Yodel -Yodel is a free & open source YouTube Music Downloader ...L\* +# ![App Icon](.github/res/app_icon_smol.png) Yodel +Yodel is a free & open source YouTube Music DownLoader\* \* i'm not good with acronyms ## Features -- Based on [youtube-dl](https://github.com/ytdl-org/youtube-dl) -- [ID3v2](https://en.wikipedia.org/wiki/ID3) tagging for MP3 - Material Design - Day & Night Theme +- Written in Kotlin with [MVVM](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel) +- [youtube-dl](https://github.com/ytdl-org/youtube-dl) for downloading +- [ID3v2](https://en.wikipedia.org/wiki/ID3) tagging for MP3 using [MP3agic](https://github.com/mpatric/mp3agic) - IDK, it's neat i guess ## Download -Get the app from the [releases page](/releases). +Get the app from the [releases page](https://github.com/shadow578/Yodel/releases/latest). ## Issues, Feature Requests and Contributing @@ -24,7 +25,7 @@ Please read the full guidelines first. Your issue may be closed if you don't.

Issues -- Before reporting a new issue, take a look at already opened [issues](/issues). +- Before reporting a new issue, take a look at already opened [issues](https://github.com/shadow578/Yodel/issues). - Do not group unrelated requests into one issue.
From 57e7bc2e4b9984b7371e5e80b55a5b456d08f497 Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Wed, 4 Aug 2021 18:08:40 +0200 Subject: [PATCH 11/22] add issue & PR templates --- .github/FUNDING.yml | 1 + .github/ISSUE_TEMPLATE.md | 3 + .github/ISSUE_TEMPLATE/bug_report.yaml | 104 ++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yaml | 45 +++++++++ .github/PULL_REQUEST_TEMPLATE.md | 20 ++++ 6 files changed, 174 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yaml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yaml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..dedc6c4 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: shadow578 diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..65ad5f0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,3 @@ +?! How did you get here? ?! + +If you want to create a issue, have a look [here](https://github.com/shadow578/Yodel/issues) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..43c2529 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,104 @@ +name: 🐞 Bug Report +description: Report a bug to help improve Yodel +title: "[BUG]: " +labels: [bug] +body: + # a short welcome to the user <3 + - type: markdown + attributes: + value: | + Thank you for taking the time to fill out this bug report! + + # app version (totally not a trick- question :P) + - type: input + id: app-version + attributes: + label: Yodel version + description: You can find the Yodel version in the app settings + placeholder: | + Example: '1.0' + validations: + required: true + + # android version + - type: input + id: android-version + attributes: + label: Android Version + description: You can find your Android version somewhere in the Android Settings (often in 'About this Phone') + placeholder: | + Example: 'Android 11' + validations: + required: true + + # device model + - type: input + id: device-model + attributes: + label: Device + description: Please State your Device model and Manufacturer + placeholder: | + Example: 'Google Pixel 4a' + validations: + required: true + + # issue details + - type: textarea + id: steps-to-reproduce + attributes: + label: Steps to reproduce + description: Explain what you did when the bug happened + placeholder: | + 1. Click This + 2. Do that + 3. Stuff breaks + validations: + required: true + + # what should happen + - type: textarea + id: expected-behavior + attributes: + label: Expected Behavior + description: Explain what should've happened + placeholder: | + This thing should have happened + validations: + required: true + + # what actually happened + - type: textarea + id: actual-behavior + attributes: + label: Actual Behavior + description: Explain what actually happened + placeholder: | + This thing actually happened + validations: + required: true + + # extra details + - type: textarea + id: extra + attributes: + label: Extra Details + placeholder: | + Additional Details, Attachments, or logs + + # acknowledgements before finishing the issue + - type: checkboxes + id: acknowledgements + attributes: + label: Acknowledgements + description: Please make sure to read through this carefully. IF you skimmed through this, your issue will be closed. + options: + - label: I have searched the existing issues and this is a **new** bug + required: true + - label: I have written a **informative** title + required: true + - label: I filled out **all** the information requested in this form + required: true + - label: I have updated to the **[latest app version](https://github.com/shadow578/Yodel/releases/latest)** + required: true + - label: My Phone runs on at least Android 6.0 + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000..6a5190e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,45 @@ +name: ⭐ Feature Request +description: Suggest a feature that would improve Yodel +title: "[FEATURE]: " +labels: [enhancement] +body: + # a short welcome to the user <3 + - type: markdown + attributes: + value: | + Thank you for your interest in the development of Yodel! + + # feature description + - type: textarea + id: feature-description + attributes: + label: Feature Description + description: Describe your feature here + placeholder: | + You should add this thing... + validations: + required: true + + # extra details + - type: textarea + id: extra + attributes: + label: Extra Details + placeholder: | + Additional Details, Attachments, maybe mockups for UI changes, ... + + # acknowledgements before finishing the issue + - type: checkboxes + id: acknowledgements + attributes: + label: Acknowledgements + description: Please make sure to read through this carefully. IF you skimmed through this, your feature request will be closed. + options: + - label: I have searched the existing issues and this is a **new** feature request + required: true + - label: I have written a **informative** title + required: true + - label: I filled out **all** the information requested in this form + required: true + - label: I have updated to the **[latest app version](https://github.com/shadow578/Yodel/releases/latest)** and the feature is still missing + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1803d85 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ + + + +--- + +## Code Quality + + + + +- [ ] I followed the [Code Style Guidelines](https://github.com/shadow578/Yodel/blob/develop/CONTRIBUTING.md#code-style-guidelines) +- [ ] All Unit- and Instrumented Tests still pass +- [ ] My Code generated no new warnings (Analyze > Inspect Code) +- [ ] I updated the documentation + + + From 0479377847ae5bc706e5004f101aab828255fddc Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Thu, 5 Aug 2021 18:55:08 +0200 Subject: [PATCH 12/22] add CI and auto- build --- .github/gh-gradle.properties | 5 + .github/workflows/auto_build.yml | 157 +++++++++++++++++++++++++++ .github/workflows/full_ci.yml | 175 +++++++++++++++++++++++++++++++ .github/workflows/short_ci.yml | 82 +++++++++++++++ .gitignore | 2 + BUILDING.md | 83 +++++++++++++++ CONTRIBUTING.md | 16 +++ README.md | 5 +- app/build.gradle.kts | 31 +++++- sign.properties.example | 19 ++++ 10 files changed, 569 insertions(+), 6 deletions(-) create mode 100644 .github/gh-gradle.properties create mode 100644 .github/workflows/auto_build.yml create mode 100644 .github/workflows/full_ci.yml create mode 100644 .github/workflows/short_ci.yml create mode 100644 BUILDING.md create mode 100644 sign.properties.example diff --git a/.github/gh-gradle.properties b/.github/gh-gradle.properties new file mode 100644 index 0000000..376477e --- /dev/null +++ b/.github/gh-gradle.properties @@ -0,0 +1,5 @@ +org.gradle.daemon=false +org.gradle.parallel=true +org.gradle.jvmargs=-Xmx5g -XX:+UseParallelGC +org.gradle.workers.max=2 +kotlin.incremental=false diff --git a/.github/workflows/auto_build.yml b/.github/workflows/auto_build.yml new file mode 100644 index 0000000..0d4af1c --- /dev/null +++ b/.github/workflows/auto_build.yml @@ -0,0 +1,157 @@ +# Action to automatically build the project when a new tag is created +# always uses the 'main' branch for building +# also, this action runs unit tests before the build (only published as artifact) + +name: Build and Publish +on: + # new tag pushed + push: + tags: + - '*' + + # and manual run + workflow_dispatch: +jobs: + # build release + build_apk: + name: Build APK + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + JAVA_TOOL_OPTIONS: -Xmx5g -XX:+UseParallelGC + steps: + # setup env + - uses: actions/checkout@v2 + with: + ref: main + - uses: actions/setup-java@v1 + with: + java-version: 11 + - run: | + mkdir -p ~/.gradle + cp .github/gh-gradle.properties ~/.gradle/gradle.properties + chmod +x ./gradlew + + # write sign.properties + - name: Write Signing Config + run: | + # write keystore + echo "${{ secrets.KEY_STORE }}" | base64 -d > ./keystore.jks + + # write sign.properties + cat < ./sign.properties + # key alias to use for application signing + key_alias=${{ secrets.ALIAS }} + + # key password + key_password=${{ secrets.KEY_PASSWORD }} + + # path of the keystore to use for signing + # relative to the project root (where this file also is) + keystore_path=keystore.jks + + # password for the keystore + keystore_password=${{ secrets.KEY_STORE_PASSWORD}} + EOT + + # run gradle build task + - name: Run build + uses: eskatos/gradle-command-action@v1 + with: + arguments: testDebugUnitTest assembleRelease + wrapper-cache-enabled: true + dependencies-cache-enabled: true + configuration-cache-enabled: true + + # delete universal APK to slim down artifact upload + - run: rm -f ./app/build/outputs/apk/release/app-universal-release.apk + + # print contents of artifact dir + - run: ls ./app/build/outputs/apk/release + + # print contents of artifact dir + - run: ls ./app/build/test-results/testDebugUnitTest + + # upload results + - name: Upload Test Results + uses: actions/upload-artifact@v2 + if: always() + with: + name: unit-test-results + path: ./app/build/test-results/testDebugUnitTest/*.xml + + # upload artifacts + - name: Upload Artifacts + uses: actions/upload-artifact@v2 + with: + name: apks + path: ./app/build/outputs/apk/release/*.apk + + # release to github + release_github: + name: Release On Github + needs: build_apk + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # download artifacts + - run: mkdir ./release/ + - name: Download Artifacts + uses: actions/download-artifact@v2 + with: + name: apks + path: ./release/ + + # print contents of artifact dir + - run: ls ./release + + # calculate file hashes + - name: Calculate Hashes + id: apk_hash + shell: bash + run: | + echo ::set-output name=arm::$(sha256sum "./release/app-armeabi-v7a-release.apk") + echo ::set-output name=arm64::$(sha256sum "./release/app-arm64-v8a-release.apk") + echo ::set-output name=x86::$(sha256sum "./release/app-x86-release.apk") + echo ::set-output name=x64::$(sha256sum "./release/app-x86_64-release.apk") + + # print hashes + - run: | + echo ${{ steps.apk_hash.outputs.arm }} + echo ${{ steps.apk_hash.outputs.arm64 }} + echo ${{ steps.apk_hash.outputs.x86 }} + echo ${{ steps.apk_hash.outputs.x64 }} + + # create new release + - name: Create the Release + uses: ncipollo/release-action@v1 + with: + body: | + --- + + APK Hashes: + + - ${{ steps.apk_hash.outputs.arm }} + - ${{ steps.apk_hash.outputs.arm64 }} + - ${{ steps.apk_hash.outputs.x86 }} + - ${{ steps.apk_hash.outputs.x64 }} + + > 🤖 this release was built automatically using Github Actions + artifacts: "./release/*.apk" + allowUpdates: true + omitBody: false + omitBodyDuringUpdate: false + token: ${{ secrets.GITHUB_TOKEN }} + + # remove artifacts + delete_artifacts: + name: Delete Artifacts + needs: release_github + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Delete Artifacts + uses: geekyeggo/delete-artifact@v1 + with: + name: apks + failOnError: false diff --git a/.github/workflows/full_ci.yml b/.github/workflows/full_ci.yml new file mode 100644 index 0000000..ac4222b --- /dev/null +++ b/.github/workflows/full_ci.yml @@ -0,0 +1,175 @@ +# Action to automatically (and magically) run unit and instrumentation tests on the project +# and publish the results as a comment on the PR +# main action used to make this work are: +# - eskatos/gradle-command-action@v1 +# - reactivecircus/android-emulator-runner@v2 +# - EnricoMi/publish-unit-test-result-action@v1 + +name: CI (Unit + Instrumentation Tests) +on: + # run on pull request to main (release) & develop (feature merge) + pull_request: + branches: + - main + #- develop + + # and manual run + workflow_dispatch: +jobs: + # run unit tests + unit_test: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + JAVA_TOOL_OPTIONS: -Xmx5g -XX:+UseParallelGC + steps: + # setup env + - uses: actions/checkout@v2 + - uses: actions/setup-java@v1 + with: + java-version: 11 + - run: | + mkdir -p ~/.gradle + cp .github/gh-gradle.properties ~/.gradle/gradle.properties + chmod +x ./gradlew + + # run gradle unit test task + - name: Run unit tests + uses: eskatos/gradle-command-action@v1 + with: + arguments: testDebugUnitTest + wrapper-cache-enabled: true + dependencies-cache-enabled: true + configuration-cache-enabled: true + + # print contents of artifact dir + - run: ls ./app/build/test-results/testDebugUnitTest + + # upload results + - name: Upload Test Results + uses: actions/upload-artifact@v2 + with: + name: unit-test-results + path: ./app/build/test-results/testDebugUnitTest/*.xml + + # run instrumentation test + instrumentation_test: + name: Instrumentation Tests + needs: unit_test + runs-on: macos-latest + timeout-minutes: 20 + env: + JAVA_TOOL_OPTIONS: -Xmx5g -XX:+UseParallelGC + strategy: + fail-fast: true + matrix: + # keep this list short, with only key versions in it + # the instrumentation tests run on macOS, which consumes 10x the minutes a linux job would + # making these test rather costly + api-level: [23, 26, 30] + steps: + # setup env + - uses: actions/checkout@v2 + - uses: actions/setup-java@v1 + with: + java-version: 11 + - run: | + mkdir -p ~/.gradle + cp .github/gh-gradle.properties ~/.gradle/gradle.properties + chmod +x ./gradlew + + # get avd target (API 30+ only has google api images) + - name: Get AVD Target + id: avd-target + run: echo "::set-output name=target::$(if [ ${{ matrix.api-level }} -ge 30 ]; then echo google_apis; else echo default; fi)" + + # setup caches (this is based on the example given at https://github.com/marketplace/actions/android-emulator-runner#usage) + - name: Gradle cache + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/buildSrc/**/*.kt') }} + + - name: AVD cache + uses: actions/cache@v2 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }} + + # generate avd for cache + - name: create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ steps.avd-target.outputs.target }} + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + # run tests + - name: run tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ steps.avd-target.outputs.target }} + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew connectedCheck + + # print contents of artifact dir + - run: ls ./app/build/outputs/androidTest-results/connected + + # upload results + - name: Upload Test Results + uses: actions/upload-artifact@v2 + with: + name: instrumentation-test-results + path: ./app/build/outputs/androidTest-results/connected/*.xml + + # publish test results (even if tests failed) + publish_results: + name: Publish Test Results + needs: [unit_test, instrumentation_test] + if: always() + runs-on: ubuntu-latest + steps: + # download unit test results + - run: mkdir ./results/ + - name: Download Unit Test Results + uses: actions/download-artifact@v2 + with: + name: unit-test-results + path: ./results/ + + # print contents of artifact dir + - run: ls ./results + + # download instrumentation test results + - name: Download Instrumentation Test Results + uses: actions/download-artifact@v2 + with: + name: instrumentation-test-results + path: ./results/ + + # print contents of artifact dir + - run: ls ./results + + # publish results + - name: Publish test results + uses: EnricoMi/publish-unit-test-result-action@v1 + if: always() + with: + check_name: Test Results (Instrumented + Unit Tests) + report_individual_runs: true + github_token: ${{ secrets.GITHUB_TOKEN }} + files: | + ./results/*.xml diff --git a/.github/workflows/short_ci.yml b/.github/workflows/short_ci.yml new file mode 100644 index 0000000..157f163 --- /dev/null +++ b/.github/workflows/short_ci.yml @@ -0,0 +1,82 @@ +# Action to automatically run unit tests on the project +# and publish the results as a comment on the PR +# main action used to make this work are: +# - eskatos/gradle-command-action@v1 +# - EnricoMi/publish-unit-test-result-action@v1 + +name: Short CI (Unit Tests only) +on: + # run on pull request to main (release) & develop (feature merge) + pull_request: + branches: + #- main + - develop + + # and manual run + workflow_dispatch: +jobs: + # run unit tests + unit_test: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + JAVA_TOOL_OPTIONS: -Xmx5g -XX:+UseParallelGC + steps: + # setup env + - uses: actions/checkout@v2 + - uses: actions/setup-java@v1 + with: + java-version: 11 + - run: | + mkdir -p ~/.gradle + cp .github/gh-gradle.properties ~/.gradle/gradle.properties + chmod +x ./gradlew + + # run gradle unit test task + - name: Run unit tests + uses: eskatos/gradle-command-action@v1 + with: + arguments: testDebugUnitTest + wrapper-cache-enabled: true + dependencies-cache-enabled: true + configuration-cache-enabled: true + + # print contents of artifact dir + - run: ls ./app/build/test-results/testDebugUnitTest + + # upload results + - name: Upload Test Results + uses: actions/upload-artifact@v2 + with: + name: unit-test-results + path: ./app/build/test-results/testDebugUnitTest/*.xml + + # publish test results (even if tests failed) + publish_results: + name: Publish Test Results + needs: unit_test + if: always() + runs-on: ubuntu-latest + steps: + # download unit test results + - run: mkdir ./results/ + - name: Download Unit Test Results + uses: actions/download-artifact@v2 + with: + name: unit-test-results + path: ./results/ + + # print contents of artifact dir + - run: ls ./results + + # publish results + - name: Publish test results + uses: EnricoMi/publish-unit-test-result-action@v1 + if: always() + with: + check_name: Test Results (Unit Tests Only) + report_individual_runs: true + github_token: ${{ secrets.GITHUB_TOKEN }} + files: | + ./results/*.xml diff --git a/.gitignore b/.gitignore index 4bd8efa..f1ecddf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .exclude/ /app/build.properties +sign.properties +*.jks # AS default .gradle diff --git a/BUILDING.md b/BUILDING.md new file mode 100644 index 0000000..d61a138 --- /dev/null +++ b/BUILDING.md @@ -0,0 +1,83 @@ +# Development Workflow + +These workflows describe how a developer would work with Yodels build and CI system. + +## for new Features + +To create a new feature: + +1. update your local copy of the `develop` branch +2. create a new branch ('feature/my_feature') + - 'feature/' for features + - 'fix/' for bugfixes\* + - 'locale/' for locale additions / changes +3. when you are done, create a pull request into `develop` +4. once CI passed and reviews are done, you merge into develop (and are done) + +> \* sometimes, a bugfix is implemented alongside a new feature. this is ok. you do __not__ have to create an additional branch just to fix a bug. instead, just include it with your feature. + + +## for creating a new Release + +To publish a new release, you first have to merge `develop` into `main`: + +1. commit any last- minute changes now (into `develop`) + - bump the app version + - ensure the app is ready to ship + - run unit __and__ instrumentation tests on your local machine (full CI is expensive) +2. on Github, create a new pull request that merges `develop` into `main` +3. once CI passed and reviews are done, you merge into main + +Now, you have to create the release: + +1. on Github, draft a new release + - use the app version (versionName in build.gradle) as tag + - use `main` as the target + - do __not__ fill out the title & body (auto- build overwrites them) +2. wait for auto- build to finish +3. now you can fil out the title & body + - keep the body generated by auto- build intact + - do not rename the attached apk files + + +# Github Workflows + +Yodel is automatically built using [Github Actions](https://github.com/features/actions) with the following workflows: + +## Short CI + +[workflow file](https://github.com/shadow578/Yodel/blob/develop/.github/workflows/short_ci.yml) + +the short CI runs on every pull request that is to be merged into `develop`.
+it only runs unit tests using the `testDebugUnitTest` task. test results are published on the PR using [EnricoMi/publish-unit-test-result-action](https://github.com/EnricoMi/publish-unit-test-result-action) + +- Runs on __all__ PRs into `develop` +- Unit Tests Only + + +## Full CI + +[workflow file](https://github.com/shadow578/Yodel/blob/develop/.github/workflows/full_ci.yml) + +the full CI runs only on pull requests that are to be merged into `main` (new releases).
+this is because it includes instrumentation tests, which (at the moment) can only be run on macOS runners. These, however, use up the run minute quota [10x faster](https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#minute-multipliers).
+This makes instrumentation tests quite expensive. on the free tier, you could run the full CI only 10 (-ish) times a month. + +- Runs on __all__ PRs into `main` +- Unit + Instrumentation Tests +- Very expensive to run + + +## Auto- Build + +[workflow file](https://github.com/shadow578/Yodel/blob/develop/.github/workflows/auto_build.yml) + +this workflow automatically builds yodel into four apk files (for each ABI) and creates a release on github for them.
+it also includes unit tests (like the short CI), but does not publish them in any visual way (instead, a artifact of the xml files is uploaded).
+at the end of this workflow, the artifact containing the built apk files is deleted. +This is to limit the impact on the storage quota (all four apks total almost 120Mb, or about 1/4 of the total quota) + +- Runs when a new __tag / release__ is pushed / created +- Overwrites existing release body +- Unit Tests + App Build +- Calculates and publishes checksums for the APK files diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4421154..a98544e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,22 @@ When creating a fork, remember to: - Change the 'applicationId' in build.gradle +if you want to use Yodels automatic build system, have a look [at the build guide](BUILDING.md) + + +# Branches + +Use the following prefixes for branches: + +Prefix | Function +-|- +feature/ | new features and improvements to existing features +fix/ | bugfixes\* +locale/ | for locale additions and updates + + +> \* sometimes, a bugfix is implemented alongside a new feature. this is ok. you do __not__ have to create an additional branch just to fix a bug. instead, just include it with your feature. + # Code Style Guidelines diff --git a/README.md b/README.md index e7774e0..8ae32d4 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,10 @@ Yodel is a free & open source YouTube Music DownLoader\* ## Download -Get the app from the [releases page](https://github.com/shadow578/Yodel/releases/latest). + +Yodel is automatically built using Github Actions.
+If you just want to download the apk, take a look at the [releases page](https://github.com/shadow578/Yodel/releases/latest).
+If you want to know more about the build system, have a look [at the build guide](BUILDING.md) ## Issues, Feature Requests and Contributing diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b8786c9..9e52d62 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.io.FileInputStream +import java.util.* + plugins { id("com.android.application") id("kotlin-android") @@ -5,6 +8,13 @@ plugins { id("com.mikepenz.aboutlibraries.plugin") } +// load signing config +val signProps = Properties() +val signPropsFile = project.file("../sign.properties") +val useSignProps = signPropsFile.exists() +if (useSignProps) + FileInputStream(signPropsFile).use { signProps.load(it) } + android { compileSdk = 30 buildToolsVersion = "30.0.3" @@ -18,22 +28,33 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - ndk { - abiFilters += setOf("x86", "x86_64", "armeabi-v7a", "arm64-v8a") - } kapt { arguments { arg("room.schemaLocation", "$projectDir/schemas") } } } + signingConfigs { + create("from_props") { + keyAlias = signProps.getProperty("key_alias") + keyPassword = signProps.getProperty("key_password") + storeFile = file("../" + signProps.getProperty("keystore_path")) + storePassword = signProps.getProperty("keystore_password") + } + } buildTypes { - release { + getByName("release") { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + + // use sign.properties + if (useSignProps) { + println("using sign.properties for release build signing") + signingConfig = signingConfigs.getByName("from_props") + } } } compileOptions { @@ -55,7 +76,7 @@ android { isEnable = true reset() include("x86", "x86_64", "armeabi-v7a", "arm64-v8a") - isUniversalApk = true + isUniversalApk = false } } packagingOptions.resources { diff --git a/sign.properties.example b/sign.properties.example new file mode 100644 index 0000000..a854fe9 --- /dev/null +++ b/sign.properties.example @@ -0,0 +1,19 @@ +# This file must *NOT* be checked into Version Control Systems, +# as it contains confidential information. +# +# signing configuration and credentials for release builds (in automated builds) +# you can also use this to create signed builds from the +# command line (using assembleRelease), but you can also just use Android Studio for manual releases + +# key alias to use for application signing +key_alias=mykey + +# key password +key_password=MyPassword + +# path of the keystore to use for signing +# relative to the project root (where this file also is) +keystore_path=my_keystore.jks + +# password for the keystore +keystore_password=MyPassword From e3f1fa2a3639e54ba48235e8a6156fcb0842b1ea Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Fri, 6 Aug 2021 13:34:09 +0200 Subject: [PATCH 13/22] use robolectric instead of instrumentation tests --- .github/workflows/full_ci.yml | 9 ++-- .github/workflows/short_ci.yml | 2 +- CONTRIBUTING.md | 28 ++++++++--- app/build.gradle.kts | 6 ++- .../io/github/shadow578/yodel/RoboTest.kt | 49 +++++++++++++++++++ .../shadow578/yodel/util/UtilRoboTest.kt} | 12 ++--- .../util/storage/StorageHelperRoboTest.kt} | 16 +++--- 7 files changed, 94 insertions(+), 28 deletions(-) create mode 100644 app/src/test/kotlin/io/github/shadow578/yodel/RoboTest.kt rename app/src/{androidTest/kotlin/io/github/shadow578/yodel/util/UtilAndroidTest.kt => test/kotlin/io/github/shadow578/yodel/util/UtilRoboTest.kt} (70%) rename app/src/{androidTest/kotlin/io/github/shadow578/yodel/util/storage/StorageHelperAndroidTest.kt => test/kotlin/io/github/shadow578/yodel/util/storage/StorageHelperRoboTest.kt} (78%) diff --git a/.github/workflows/full_ci.yml b/.github/workflows/full_ci.yml index ac4222b..769485c 100644 --- a/.github/workflows/full_ci.yml +++ b/.github/workflows/full_ci.yml @@ -1,3 +1,6 @@ +# FULL CI is currently disabled as there are no tests in androidTest (thanks to robolectric) +# if you need instrumented tests, uncomment '- main' here and comment it in short_ci + # Action to automatically (and magically) run unit and instrumentation tests on the project # and publish the results as a comment on the PR # main action used to make this work are: @@ -8,9 +11,9 @@ name: CI (Unit + Instrumentation Tests) on: # run on pull request to main (release) & develop (feature merge) - pull_request: - branches: - - main + #pull_request: + # branches: + #- main #- develop # and manual run diff --git a/.github/workflows/short_ci.yml b/.github/workflows/short_ci.yml index 157f163..e1d8537 100644 --- a/.github/workflows/short_ci.yml +++ b/.github/workflows/short_ci.yml @@ -9,7 +9,7 @@ on: # run on pull request to main (release) & develop (feature merge) pull_request: branches: - #- main + - main - develop # and manual run diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a98544e..dc84384 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,11 +37,11 @@ if you want to use Yodels automatic build system, have a look [at the build guid Use the following prefixes for branches: -Prefix | Function --|- -feature/ | new features and improvements to existing features -fix/ | bugfixes\* -locale/ | for locale additions and updates +| Prefix | Function | +| -------- | -------------------------------------------------- | +| feature/ | new features and improvements to existing features | +| fix/ | bugfixes\* | +| locale/ | for locale additions and updates | > \* sometimes, a bugfix is implemented alongside a new feature. this is ok. you do __not__ have to create an additional branch just to fix a bug. instead, just include it with your feature. @@ -66,5 +66,19 @@ These Guidelines outline what I think are useful rules to create readable and ma - comments should describe the class/field/method, but don't have to be too long - do __not__ ignore lint warnings - Try to include tests for your contribution (where applicable) - - Yodel uses [Kotest](https://kotest.io/) with JUnit for unit and instrumented tests - + - See [Testing](#testing) for details + + +## Testing + +- Try to include test cases where possible + - test- only contributions are welcome too +- Yodel uses [Kotest](https://kotest.io/) for assertion statements +- Unit tests and Robolectric tests go into /src/test + - Name normal unit test classes `(MyClass)Test.kt` + - Robolectric test classes shall be named `(MyClass)RoboTest.kt` and extend `RoboTest` + - Robolectric tests run across multiple SKD versions. If you only need robolectric for some test cases, please split your class +- Instrumentation tests go into /src/androidTest + - Name instrumentation tests classes `(MyClass)AndroidTest.kt` +- If possible, use [Robolectric](http://robolectric.org/) instead of instrumentation tests + - instrumentation tests are supported, but require adjustments to the full_ci and short_ci workflows diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9e52d62..5357da3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,9 @@ android { excludes.add("META-INF/LICENSE.md") excludes.add("META-INF/LICENSE-notice.md") } + testOptions.unitTests { + isIncludeAndroidResources = true + } } aboutLibraries { configPath = "aboutlibraries" @@ -126,7 +129,8 @@ dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5") // unit testing - testImplementation("junit:junit:4.13.2") + testImplementation("androidx.test.ext:junit:1.1.3") + testImplementation("org.robolectric:robolectric:4.6.1") testImplementation("io.kotest:kotest-assertions-core:4.6.1") androidTestImplementation("androidx.test:runner:1.4.0") androidTestImplementation("androidx.test:rules:1.4.0") diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/RoboTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/RoboTest.kt new file mode 100644 index 0000000..3d69fc0 --- /dev/null +++ b/app/src/test/kotlin/io/github/shadow578/yodel/RoboTest.kt @@ -0,0 +1,49 @@ +package io.github.shadow578.yodel + +import android.app.Application +import android.content.Context +import android.os.Build.VERSION_CODES.* +import androidx.preference.PreferenceManager +import androidx.test.core.app.ApplicationProvider +import io.github.shadow578.yodel.util.NotificationChannels +import io.github.shadow578.yodel.util.preferences.PreferenceWrapper +import io.kotest.matchers.nulls.shouldNotBeNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * base class for all robolectric test classes. + * this handles the config. put shared test code in here + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = YodelTestApp::class, sdk = [O, P, Q, R]) +open class RoboTest { + + /** + * check the instrumentation setup + */ + @Test + fun validateInstrumentation() { + context.shouldNotBeNull() + } + + /** + * the instrumentation context + */ + protected val context: Context + get() = ApplicationProvider.getApplicationContext() +} + +/** + * application class, for boilerplate init. + * special version for robolectric tests, does not try to mark removed tracks in db + */ +class YodelTestApp : Application() { + override fun onCreate() { + super.onCreate() + PreferenceWrapper.init(PreferenceManager.getDefaultSharedPreferences(this)) + NotificationChannels.registerAll(this) + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/UtilAndroidTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilRoboTest.kt similarity index 70% rename from app/src/androidTest/kotlin/io/github/shadow578/yodel/util/UtilAndroidTest.kt rename to app/src/test/kotlin/io/github/shadow578/yodel/util/UtilRoboTest.kt index c9042cd..877a163 100644 --- a/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/UtilAndroidTest.kt +++ b/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilRoboTest.kt @@ -1,26 +1,24 @@ package io.github.shadow578.yodel.util -import androidx.test.filters.SmallTest -import androidx.test.platform.app.InstrumentationRegistry import com.bumptech.glide.util.Util +import io.github.shadow578.yodel.RoboTest import io.kotest.assertions.withClue import io.kotest.matchers.file.* import io.kotest.matchers.nulls.shouldNotBeNull import org.junit.Test import java.io.File - /** - * instrumented test for [Util] + * robolectric test for [Util] */ -@SmallTest -class UtilAndroidTest { +class UtilRoboTest : RoboTest() { + /** * [getTempFile] */ @Test fun shouldGetTempFile() { withClue("getTempFile() should return a file in the parent directory that does not exist") { - val cache = InstrumentationRegistry.getInstrumentation().targetContext.cacheDir + val cache = context.cacheDir val temp: File = cache.getTempFile("foo", "bar") temp.shouldNotBeNull() diff --git a/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/storage/StorageHelperAndroidTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/util/storage/StorageHelperRoboTest.kt similarity index 78% rename from app/src/androidTest/kotlin/io/github/shadow578/yodel/util/storage/StorageHelperAndroidTest.kt rename to app/src/test/kotlin/io/github/shadow578/yodel/util/storage/StorageHelperRoboTest.kt index d47fc02..0fb2543 100644 --- a/app/src/androidTest/kotlin/io/github/shadow578/yodel/util/storage/StorageHelperAndroidTest.kt +++ b/app/src/test/kotlin/io/github/shadow578/yodel/util/storage/StorageHelperRoboTest.kt @@ -2,18 +2,17 @@ package io.github.shadow578.yodel.util.storage import android.net.Uri import androidx.documentfile.provider.DocumentFile -import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry +import io.github.shadow578.yodel.RoboTest import io.kotest.matchers.* import io.kotest.matchers.nulls.* import org.junit.Test import java.io.File /** - * instrumented test for StorageHelper + * robolectric test for StorageHelper */ -@SmallTest -class StorageHelperAndroidTest { +class StorageHelperRoboTest : RoboTest() { private fun beNonEmptyStorageKey() = object : Matcher { override fun test(value: StorageKey): MatcherResult = MatcherResult( value.key.isNotBlank(), @@ -27,7 +26,7 @@ class StorageHelperAndroidTest { */ @Test fun shouldEncodeAndDecodeUri() { - val cache = InstrumentationRegistry.getInstrumentation().targetContext.cacheDir + val cache = context.cacheDir val uri = Uri.fromFile(File(cache, "foo.bar")) // encode @@ -51,9 +50,8 @@ class StorageHelperAndroidTest { */ @Test fun shouldEncodeAndDecodeFile() { - val ctx = InstrumentationRegistry.getInstrumentation().targetContext - val uri = Uri.fromFile(File(ctx.cacheDir, "foo.bar")) - val file = DocumentFile.fromSingleUri(ctx, uri) + val uri = Uri.fromFile(File(context.cacheDir, "foo.bar")) + val file = DocumentFile.fromSingleUri(context, uri) // check test setup file.shouldNotBeNull() @@ -63,7 +61,7 @@ class StorageHelperAndroidTest { key should beNonEmptyStorageKey() // decode - val decodedFile: DocumentFile? = key.decodeToFile(ctx) + val decodedFile: DocumentFile? = key.decodeToFile(context) decodedFile.shouldNotBeNull() decodedFile.uri shouldBe file.uri } From 149b8c904720b51e4c1479d50fb843d1042a4c98 Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Fri, 6 Aug 2021 15:10:55 +0200 Subject: [PATCH 14/22] add more test cases --- .../shadow578/yodel/db/DBTypeConverters.kt | 7 +- .../io/github/shadow578/yodel/RoboTest.kt | 6 +- .../io/github/shadow578/yodel/TestUtil.kt | 61 +++++++ .../yodel/db/DBTypeConvertersTest.kt | 84 ++++++++++ .../shadow578/yodel/db/TracksDBRoboTest.kt | 151 ++++++++++++++++++ .../yodel/db/model/TrackStatusTest.kt | 35 ++++ .../shadow578/yodel/util/UtilRoboTest.kt | 17 +- .../preferences/PreferenceWrapperRoboTest.kt | 105 ++++++++++++ 8 files changed, 462 insertions(+), 4 deletions(-) create mode 100644 app/src/test/kotlin/io/github/shadow578/yodel/TestUtil.kt create mode 100644 app/src/test/kotlin/io/github/shadow578/yodel/db/DBTypeConvertersTest.kt create mode 100644 app/src/test/kotlin/io/github/shadow578/yodel/db/TracksDBRoboTest.kt create mode 100644 app/src/test/kotlin/io/github/shadow578/yodel/db/model/TrackStatusTest.kt create mode 100644 app/src/test/kotlin/io/github/shadow578/yodel/util/preferences/PreferenceWrapperRoboTest.kt diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/db/DBTypeConverters.kt b/app/src/main/kotlin/io/github/shadow578/yodel/db/DBTypeConverters.kt index d53060b..ec50658 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/db/DBTypeConverters.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/db/DBTypeConverters.kt @@ -4,6 +4,7 @@ import androidx.room.TypeConverter import io.github.shadow578.yodel.db.model.TrackStatus import io.github.shadow578.yodel.util.storage.StorageKey import java.time.LocalDate +import java.time.format.DateTimeParseException /** * type converters for room @@ -52,6 +53,10 @@ class DBTypeConverters { fun toLocalDate(string: String?): LocalDate? { return if (string == null) { null - } else LocalDate.parse(string) + } else try { + LocalDate.parse(string) + } catch (_ : DateTimeParseException) { + null + } } //endregion } \ No newline at end of file diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/RoboTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/RoboTest.kt index 3d69fc0..a3a4219 100644 --- a/app/src/test/kotlin/io/github/shadow578/yodel/RoboTest.kt +++ b/app/src/test/kotlin/io/github/shadow578/yodel/RoboTest.kt @@ -15,10 +15,12 @@ import org.robolectric.annotation.Config /** * base class for all robolectric test classes. - * this handles the config. put shared test code in here + * this handles the config. put shared test code in here. + * + * tests from M (minSdk) to R (targetSdk) */ @RunWith(RobolectricTestRunner::class) -@Config(application = YodelTestApp::class, sdk = [O, P, Q, R]) +@Config(application = YodelTestApp::class, sdk = [M, /*N,*/ /*N_MR1,*/ O, /*O_MR1,*/ P, /*Q,*/ R]) open class RoboTest { /** diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/TestUtil.kt b/app/src/test/kotlin/io/github/shadow578/yodel/TestUtil.kt new file mode 100644 index 0000000..602fb86 --- /dev/null +++ b/app/src/test/kotlin/io/github/shadow578/yodel/TestUtil.kt @@ -0,0 +1,61 @@ +package io.github.shadow578.yodel + +import androidx.arch.core.executor.* +import androidx.lifecycle.* +import java.util.concurrent.* + +/** + * Gets the value of a [LiveData] or waits for it to have one, with a timeout. + * + * Use this extension from host-side (JVM) tests. It's recommended to use it alongside + * `InstantTaskExecutorRule` or a similar mechanism to execute tasks synchronously. + * + * + * taken from https://github.com/android/architecture-components-samples/blob/8f4936b34ec84f7f058fba9732b8692e97c65d8f/LiveDataSample/app/src/test/java/com/android/example/livedatabuilder/util/LiveDataTestUtil.kt + */ +fun LiveData.getOrAwaitValue( + time: Long = 2, + timeUnit: TimeUnit = TimeUnit.SECONDS, + afterObserve: () -> Unit = {} +): T { + var data: T? = null + val latch = CountDownLatch(1) + val observer = object : Observer { + override fun onChanged(o: T?) { + data = o + latch.countDown() + this@getOrAwaitValue.removeObserver(this) + } + } + this.observeForever(observer) + + afterObserve.invoke() + + // Don't wait indefinitely if the LiveData is not set. + if (!latch.await(time, timeUnit)) { + this.removeObserver(observer) + throw TimeoutException("LiveData value was never set.") + } + + @Suppress("UNCHECKED_CAST") + return data as T +} + +/** + * makes the arch components (android room, ...) run all functions on the main thread + */ +fun runArchSingleThreaded() { + ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() { + override fun executeOnDiskIO(runnable: Runnable) { + runnable.run() + } + + override fun postToMainThread(runnable: Runnable) { + runnable.run() + } + + override fun isMainThread(): Boolean { + return true + } + }) +} \ No newline at end of file diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/db/DBTypeConvertersTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/db/DBTypeConvertersTest.kt new file mode 100644 index 0000000..6484111 --- /dev/null +++ b/app/src/test/kotlin/io/github/shadow578/yodel/db/DBTypeConvertersTest.kt @@ -0,0 +1,84 @@ +package io.github.shadow578.yodel.db + +import io.github.shadow578.yodel.db.model.TrackStatus +import io.github.shadow578.yodel.util.storage.StorageKey +import io.kotest.matchers.nulls.* +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.* +import org.junit.Test +import java.time.LocalDate + +/** + * [DBTypeConverters] + */ +class DBTypeConvertersTest { + + private val conv = DBTypeConverters() + + /** + * [DBTypeConverters.fromStorageKey] + * [DBTypeConverters.toStorageKey] + */ + @Test + fun shouldConvertStorageKey() { + // key -> string -> key + val originalKey = StorageKey("aabbccdd") + val string = conv.fromStorageKey(originalKey) + string.shouldNotBeNull() + string.shouldNotBeBlank() + + conv.toStorageKey(string) shouldBe originalKey + + // null -> string -> key + conv.fromStorageKey(null).shouldBeNull() + conv.toStorageKey(null).shouldBeNull() + + // emtpy string + conv.toStorageKey("") shouldBe StorageKey.EMPTY + } + + /** + * [DBTypeConverters.fromTrackStatus] + * [DBTypeConverters.toTrackStatus] + */ + @Test + fun shouldConvertTrackStatus() { + // status -> string -> status + val originalStatus = TrackStatus.Downloading + val string = conv.fromTrackStatus(originalStatus) + string.shouldNotBeNull() + string shouldBe "downloading" + + conv.toTrackStatus(string) shouldBe TrackStatus.Downloading + + // null -> string -> status + conv.fromTrackStatus(null).shouldBeNull() + conv.toTrackStatus(null).shouldBeNull() + + // empty string + conv.toTrackStatus("") shouldBe TrackStatus.DownloadPending + } + + /** + * [DBTypeConverters.fromLocalDate] + * [DBTypeConverters.toLocalDate] + */ + @Test + fun shouldConvertLocalDate() { + // date -> string -> date + val originalDate = LocalDate.of(2021, 8, 6) + val string = conv.fromLocalDate(originalDate) + string.shouldNotBeNull() + string.shouldNotBeEmpty() + // info: no check for the serialized format as we don't really care about it + + conv.toLocalDate(string) shouldBe originalDate + + // null -> string -> date + conv.fromLocalDate(null).shouldBeNull() + conv.toLocalDate(null).shouldBeNull() + + // empty string + conv.toLocalDate("").shouldBeNull() + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/db/TracksDBRoboTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/db/TracksDBRoboTest.kt new file mode 100644 index 0000000..5c131f5 --- /dev/null +++ b/app/src/test/kotlin/io/github/shadow578/yodel/db/TracksDBRoboTest.kt @@ -0,0 +1,151 @@ +package io.github.shadow578.yodel.db + +import androidx.room.Room +import io.github.shadow578.yodel.* +import io.github.shadow578.yodel.db.model.* +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import org.junit.* + +/** + * [TracksDB] + */ +class TracksDBRoboTest : RoboTest() { + lateinit var db: TracksDB + + @Before + fun initDb() { + runArchSingleThreaded() + + // this uses the same config as TracksDB.get(), but in- memory and with allowMainThreadQueries() + db = Room.inMemoryDatabaseBuilder( + context, + TracksDB::class.java + ) + .allowMainThreadQueries() + .fallbackToDestructiveMigration() + .build() + + // insert some tracks + db.tracks().insertAll( + listOf( + TrackInfo("aabbcc", "A Title"), + TrackInfo("bbccdd", "B Title"), + TrackInfo("ccddee", "C Title", status = TrackStatus.Downloaded), + TrackInfo("ddeeff", "D Title", status = TrackStatus.Downloaded), + TrackInfo("eeffgg", "E Title", status = TrackStatus.Downloading) + ) + ) + } + + @After + fun closeDb() { + db.close() + } + + @Test + fun shouldGetAll() { + db.tracks().all shouldContainExactlyInAnyOrder listOf( + TrackInfo("aabbcc", "A Title"), + TrackInfo("bbccdd", "B Title"), + TrackInfo("ccddee", "C Title", status = TrackStatus.Downloaded), + TrackInfo("ddeeff", "D Title", status = TrackStatus.Downloaded), + TrackInfo("eeffgg", "E Title", status = TrackStatus.Downloading) + ) + } + + + @Test + fun observeShouldEqualAll() { + val all = db.tracks().all + db.tracks().observe().getOrAwaitValue() shouldContainExactlyInAnyOrder all + } + + @Test + fun observePendingShouldGetPending() { + db.tracks().observePending().getOrAwaitValue() shouldContainExactlyInAnyOrder listOf( + TrackInfo("aabbcc", "A Title"), + TrackInfo("bbccdd", "B Title") + ) + } + + @Test + fun shouldGetDownloaded() { + db.tracks().downloaded shouldContainExactlyInAnyOrder listOf( + TrackInfo("ccddee", "C Title", status = TrackStatus.Downloaded), + TrackInfo("ddeeff", "D Title", status = TrackStatus.Downloaded) + ) + } + + @Test + fun shouldResetDownloading() { + db.tracks().resetDownloadingToPending() + db.tracks().all shouldContainExactlyInAnyOrder listOf( + TrackInfo("aabbcc", "A Title"), + TrackInfo("bbccdd", "B Title"), + TrackInfo("ccddee", "C Title", status = TrackStatus.Downloaded), + TrackInfo("ddeeff", "D Title", status = TrackStatus.Downloaded), + TrackInfo("eeffgg", "E Title", status = TrackStatus.DownloadPending) + ) + } + + @Test + fun shouldGet() { + db.tracks()["aabbcc"] shouldBe TrackInfo("aabbcc", "A Title") + } + + @Test + fun shouldInsertOverwriting() { + db.tracks().insertAllNew( + listOf( + TrackInfo("ccddee", "G Title", status = TrackStatus.Downloaded), + TrackInfo("ffffgg", "F Title"), + ) + ) + db.tracks().all shouldContainExactlyInAnyOrder listOf( + TrackInfo("aabbcc", "A Title"), + TrackInfo("bbccdd", "B Title"), + TrackInfo("ccddee", "G Title", status = TrackStatus.Downloaded), + TrackInfo("ddeeff", "D Title", status = TrackStatus.Downloaded), + TrackInfo("eeffgg", "E Title", status = TrackStatus.Downloading), + TrackInfo("ffffgg", "F Title"), + ) + } + + @Test + fun shouldInsertNew() { + db.tracks().insertAllNew( + listOf( + TrackInfo("ccddee", "G Title", status = TrackStatus.Downloaded), + TrackInfo("ffffgg", "F Title"), + ) + ) + db.tracks().all shouldContainExactlyInAnyOrder listOf( + TrackInfo("aabbcc", "A Title"), + TrackInfo("bbccdd", "B Title"), + TrackInfo("ccddee", "C Title", status = TrackStatus.Downloaded), + TrackInfo("ddeeff", "D Title", status = TrackStatus.Downloaded), + TrackInfo("eeffgg", "E Title", status = TrackStatus.Downloading), + TrackInfo("ffffgg", "F Title"), + ) + } + + @Test + fun shouldUpdate() { + db.tracks()["aabbcc"] shouldBe TrackInfo("aabbcc", "A Title") + db.tracks().update(TrackInfo("aabbcc", "FooBar", status = TrackStatus.Downloaded)) + db.tracks()["aabbcc"] shouldBe TrackInfo( + "aabbcc", + "FooBar", + status = TrackStatus.Downloaded + ) + } + + @Test + fun shouldRemove() { + db.tracks()["aabbcc"] shouldBe TrackInfo("aabbcc", "A Title") + db.tracks().remove(TrackInfo("aabbcc", "FooBar")) + db.tracks()["aabbcc"].shouldBeNull() + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/db/model/TrackStatusTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/db/model/TrackStatusTest.kt new file mode 100644 index 0000000..0962e88 --- /dev/null +++ b/app/src/test/kotlin/io/github/shadow578/yodel/db/model/TrackStatusTest.kt @@ -0,0 +1,35 @@ +package io.github.shadow578.yodel.db.model + +import io.kotest.assertions.withClue +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import org.junit.Test + +/** + * [TrackStatus] + */ +class TrackStatusTest { + + @Test + fun shouldFindValidKeys() { + withClue("TrackStatus values are identified by a key that is used in SQL queries. the keys have to be resolvable by TrackStatus.findByKey()") + { + TrackStatus.findByKey("pending") shouldBe TrackStatus.DownloadPending + TrackStatus.findByKey("downloading") shouldBe TrackStatus.Downloading + TrackStatus.findByKey("downloaded") shouldBe TrackStatus.Downloaded + TrackStatus.findByKey("failed") shouldBe TrackStatus.DownloadFailed + TrackStatus.findByKey("deleted") shouldBe TrackStatus.FileDeleted + } + } + + @Test + fun shouldNotFindInvalidKeys() { + withClue("TrackStatus.findByKey() should return null for invalid keys") + { + TrackStatus.findByKey("").shouldBeNull() + TrackStatus.findByKey("ree").shouldBeNull() + TrackStatus.findByKey("pendingDownload").shouldBeNull() + TrackStatus.findByKey("Downloaded").shouldBeNull() + } + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilRoboTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilRoboTest.kt index 877a163..39180a3 100644 --- a/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilRoboTest.kt +++ b/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilRoboTest.kt @@ -1,10 +1,12 @@ package io.github.shadow578.yodel.util import com.bumptech.glide.util.Util -import io.github.shadow578.yodel.RoboTest +import io.github.shadow578.yodel.* +import io.github.shadow578.yodel.util.preferences.Prefs import io.kotest.assertions.withClue import io.kotest.matchers.file.* import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe import org.junit.Test import java.io.File /** @@ -26,4 +28,17 @@ class UtilRoboTest : RoboTest() { temp.shouldStartWithPath(cache) } } + + /** + * [wrapLocale] + */ + @Test + fun testWrapLocale(){ + Prefs.AppLocaleOverride.set(LocaleOverride.German) + Prefs.AppLocaleOverride.get() shouldBe LocaleOverride.German + + val wrapped = context.wrapLocale() + wrapped.shouldNotBeNull() + wrapped.resources.configuration.locales.get(0) shouldBe LocaleOverride.German.locale + } } \ No newline at end of file diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/util/preferences/PreferenceWrapperRoboTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/util/preferences/PreferenceWrapperRoboTest.kt new file mode 100644 index 0000000..b13851f --- /dev/null +++ b/app/src/test/kotlin/io/github/shadow578/yodel/util/preferences/PreferenceWrapperRoboTest.kt @@ -0,0 +1,105 @@ +package io.github.shadow578.yodel.util.preferences + +import io.github.shadow578.yodel.RoboTest +import io.kotest.assertions.withClue +import io.kotest.matchers.shouldBe +import org.junit.* + +/** + * robolectric tests for [PreferenceWrapper] + */ +class PreferenceWrapperRoboTest : RoboTest() { + lateinit var stringPref: PreferenceWrapper + lateinit var booleanPref: PreferenceWrapper + lateinit var objectPref: PreferenceWrapper + + @Before + fun setupPrefs() { + stringPref = PreferenceWrapper.create(String::class.java, "string_pref", "foo") + booleanPref = PreferenceWrapper.create(Boolean::class.java, "boolean_pref", false) + objectPref = + PreferenceWrapper.create(CustomClass::class.java, "object_pref", CustomClass("bar")) + + stringPref.reset() + booleanPref.reset() + objectPref.reset() + } + + @Test + fun shouldHaveDefaultValues() { + withClue("prefs are not set. they should return their default values") + { + stringPref.get() shouldBe "foo" + booleanPref.get() shouldBe false + objectPref.get() shouldBe CustomClass("bar") + } + } + + @Test + fun shouldUseFallbackValues() { + withClue("prefs are not set. they should return their defined fallback values") + { + stringPref.get("bar") shouldBe "bar" + booleanPref.get(true) shouldBe true + objectPref.get(CustomClass("yee")) shouldBe CustomClass("yee") + } + } + + @Test + fun shouldApplySetValues() { + withClue("prefs should set their values correctly") + { + // set + stringPref.set("yee") + booleanPref.set(true) + objectPref.set(CustomClass("ree")) + + // check values + stringPref.get() shouldBe "yee" + booleanPref.get() shouldBe true + objectPref.get() shouldBe CustomClass("ree") + } + } + + @Test + fun shouldNotUseFallbackValues() { + withClue("prefs that are set should not return their fallback values") + { + // set + stringPref.set("yee") + booleanPref.set(true) + objectPref.set(CustomClass("ree")) + + // check values + stringPref.get("ree") shouldBe "yee" + booleanPref.get(false) shouldBe true + objectPref.get(CustomClass("se_no")) shouldBe CustomClass("ree") + } + } + + @Test + fun shouldResetValues() { + withClue("prefs should reset their values to default") + { + // set + stringPref.set("yee") + booleanPref.set(true) + objectPref.set(CustomClass("ree")) + + // check values + stringPref.get() shouldBe "yee" + booleanPref.get() shouldBe true + objectPref.get() shouldBe CustomClass("ree") + + // reset + stringPref.reset() + booleanPref.reset() + objectPref.reset() + + // check + shouldHaveDefaultValues() + } + } + + data class CustomClass(val value: String) +} \ No newline at end of file From 6f91d3b055b190e8f0e0babb846ee3021eb46ffb Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Sat, 7 Aug 2021 13:26:06 +0200 Subject: [PATCH 15/22] fix code coverage with robolectric --- app/build.gradle.kts | 5 +++++ settings.gradle.kts | 2 ++ 2 files changed, 7 insertions(+) create mode 100644 settings.gradle.kts diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5357da3..c352859 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -136,3 +136,8 @@ dependencies { androidTestImplementation("androidx.test:rules:1.4.0") androidTestImplementation("io.kotest:kotest-assertions-core:4.6.1") } + +tasks.withType().all { + // fix for AS code coverage with robolectric + jvmArgs("-noverify", "-ea") +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..73d75cf --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,2 @@ +rootProject.name = "Yodel" +include(":app") From 201072cf8cf15258c1f623edae4ae52f1ac538a0 Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Sat, 7 Aug 2021 14:03:00 +0200 Subject: [PATCH 16/22] add tests for gson adapters + utility --- .../io/github/shadow578/yodel/RoboTest.kt | 17 ++++ .../yodel/backup/BackupGSONAdaptersTest.kt | 93 +++++++++++++++++++ .../shadow578/yodel/util/UtilRoboTest.kt | 20 +++- 3 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 app/src/test/kotlin/io/github/shadow578/yodel/backup/BackupGSONAdaptersTest.kt diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/RoboTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/RoboTest.kt index a3a4219..66afa17 100644 --- a/app/src/test/kotlin/io/github/shadow578/yodel/RoboTest.kt +++ b/app/src/test/kotlin/io/github/shadow578/yodel/RoboTest.kt @@ -2,6 +2,7 @@ package io.github.shadow578.yodel import android.app.Application import android.content.Context +import android.os.Build import android.os.Build.VERSION_CODES.* import androidx.preference.PreferenceManager import androidx.test.core.app.ApplicationProvider @@ -31,6 +32,22 @@ open class RoboTest { context.shouldNotBeNull() } + /** + * run a function block only if the SDK version is above the target + */ + fun aboveSDK(targetSdk: Int, block: () -> Unit) { + if (Build.VERSION.SDK_INT > targetSdk) + block() + } + + /** + * run a function block only if the SDK version is below or equal the target + */ + fun untilSDK(targetSdk: Int, block: () -> Unit) { + if (Build.VERSION.SDK_INT <= targetSdk) + block() + } + /** * the instrumentation context */ diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/backup/BackupGSONAdaptersTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/backup/BackupGSONAdaptersTest.kt new file mode 100644 index 0000000..8ca388e --- /dev/null +++ b/app/src/test/kotlin/io/github/shadow578/yodel/backup/BackupGSONAdaptersTest.kt @@ -0,0 +1,93 @@ +package io.github.shadow578.yodel.backup + +import com.google.gson.GsonBuilder +import io.kotest.assertions.withClue +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldBeEqualIgnoringCase +import io.kotest.matchers.string.shouldNotBeBlank +import org.junit.Test +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * [LocalDateTimeAdapter] + * [LocalDateAdapter] + */ +class BackupGSONAdaptersTest { + /** + * [LocalDateTimeAdapter] + */ + @Test + fun shouldSerializeLocalDateTime() { + val gson = GsonBuilder() + .registerTypeAdapter(LocalDateTime::class.java, LocalDateTimeAdapter()) + .create() + + withClue("non- null value") + { + val originalValue = TestDateTime(LocalDateTime.of(2021, 8, 7, 13, 0, 0)) + val json = gson.toJson(originalValue) + json.shouldNotBeNull() + json.shouldNotBeBlank() + + gson.fromJson(json, TestDateTime::class.java) shouldBe originalValue + } + + withClue("serialize null value") + { + val originalValue = TestDateTime(null) + val json = gson.toJson(originalValue) + json.shouldNotBeNull() + + json.replace(" ", "") shouldBeEqualIgnoringCase """{}""" + } + + withClue("deserialize null value") + { + gson.fromJson("""{ "value": null }""", TestDateTime::class.java) shouldBe TestDateTime(null) + } + } + + /** + * [LocalDateAdapter] + */ + @Test + fun shouldSerializeLocalDate() { + val gson = GsonBuilder() + .registerTypeAdapter(LocalDate::class.java, LocalDateAdapter()) + .create() + + withClue("non- null value") + { + val originalValue = TestDate(LocalDate.of(2021, 8, 7)) + val json = gson.toJson(originalValue) + json.shouldNotBeNull() + json.shouldNotBeBlank() + + gson.fromJson(json, TestDate::class.java) shouldBe originalValue + } + + withClue("serialize null value") + { + val originalValue = TestDate(null) + val json = gson.toJson(originalValue) + json.shouldNotBeNull() + + json.replace(" ", "") shouldBeEqualIgnoringCase """{}""" + } + + withClue("deserialize null value") + { + gson.fromJson("""{ "value": null }""", TestDate::class.java) shouldBe TestDate(null) + } + } + + data class TestDateTime( + val value: LocalDateTime? + ) + + data class TestDate( + val value: LocalDate? + ) +} \ No newline at end of file diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilRoboTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilRoboTest.kt index 39180a3..274e5a6 100644 --- a/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilRoboTest.kt +++ b/app/src/test/kotlin/io/github/shadow578/yodel/util/UtilRoboTest.kt @@ -1,14 +1,17 @@ package io.github.shadow578.yodel.util import com.bumptech.glide.util.Util -import io.github.shadow578.yodel.* +import io.github.shadow578.yodel.LocaleOverride +import io.github.shadow578.yodel.RoboTest import io.github.shadow578.yodel.util.preferences.Prefs import io.kotest.assertions.withClue -import io.kotest.matchers.file.* +import io.kotest.matchers.file.shouldNotExist +import io.kotest.matchers.file.shouldStartWithPath import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import org.junit.Test import java.io.File + /** * robolectric test for [Util] */ @@ -33,12 +36,21 @@ class UtilRoboTest : RoboTest() { * [wrapLocale] */ @Test - fun testWrapLocale(){ + fun testWrapLocale() { Prefs.AppLocaleOverride.set(LocaleOverride.German) Prefs.AppLocaleOverride.get() shouldBe LocaleOverride.German val wrapped = context.wrapLocale() wrapped.shouldNotBeNull() - wrapped.resources.configuration.locales.get(0) shouldBe LocaleOverride.German.locale + + // .locales was only added in SDK 24 + // before that, we have to use .locale (which is now deprecated) + untilSDK(23) { + wrapped.resources.configuration.locale shouldBe LocaleOverride.German.locale + } + + aboveSDK(23) { + wrapped.resources.configuration.locales.get(0) shouldBe LocaleOverride.German.locale + } } } \ No newline at end of file From 5597419faaeb0d458456b0990b4e6e6e89719d94 Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Sat, 7 Aug 2021 14:31:31 +0200 Subject: [PATCH 17/22] add tests for backup (+ refactor) --- .../shadow578/yodel/backup/BackupHelper.kt | 70 +++++----- .../shadow578/yodel/ui/more/MoreViewModel.kt | 82 ++++++----- .../yodel/backup/BackupHelperRoboTest.kt | 131 ++++++++++++++++++ 3 files changed, 209 insertions(+), 74 deletions(-) create mode 100644 app/src/test/kotlin/io/github/shadow578/yodel/backup/BackupHelperRoboTest.kt diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupHelper.kt b/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupHelper.kt index 36a496a..0436f0f 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupHelper.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/backup/BackupHelper.kt @@ -3,40 +3,52 @@ package io.github.shadow578.yodel.backup import android.content.Context import android.util.Log import androidx.documentfile.provider.DocumentFile -import com.google.gson.* +import com.google.gson.GsonBuilder +import com.google.gson.JsonIOException +import com.google.gson.JsonSyntaxException import io.github.shadow578.yodel.db.TracksDB -import java.io.* -import java.time.* -import java.util.* +import java.io.IOException +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.time.LocalDate +import java.time.LocalDateTime /** * tracks db backup helper class. * all functions must be called from a background thread + * + * @param ctx the context to work in + * @param db database to read from / write to */ -object BackupHelper { - /** - * gson for backup serialization and deserialization - */ - private val gson = GsonBuilder() - .registerTypeAdapter(LocalDateTime::class.java, LocalDateTimeAdapter()) - .registerTypeAdapter(LocalDate::class.java, LocalDateAdapter()) - .create() +class BackupHelper( + private val ctx: Context, + private val db: TracksDB = TracksDB.get(ctx) +) { + companion object { - /** - * tag for logging - */ - private const val TAG = "BackupHelper" + /** + * gson for backup serialization and deserialization + */ + private val gson = GsonBuilder() + .registerTypeAdapter(LocalDateTime::class.java, LocalDateTimeAdapter()) + .registerTypeAdapter(LocalDate::class.java, LocalDateAdapter()) + .create() + + /** + * tag for logging + */ + private const val TAG = "BackupHelper" + } /** * create a new backup of all tracks * - * @param ctx the context to work in * @param file the file to write the backup to * @return was the backup successful */ - fun createBackup(ctx: Context, file: DocumentFile): Boolean { + fun createBackup(file: DocumentFile): Boolean { // get all tracks in DB - val tracks = TracksDB.get(ctx).tracks().all + val tracks = db.tracks().all if (tracks.isEmpty()) return false // create backup data @@ -57,47 +69,43 @@ object BackupHelper { /** * read backup data from a file * - * @param ctx the context to read in * @param file the file to read the data from * @return the backup data */ - fun readBackupData(ctx: Context, file: DocumentFile): Optional { + fun readBackup(file: DocumentFile): BackupData? { try { InputStreamReader(ctx.contentResolver.openInputStream(file.uri)).use { src -> - return Optional.ofNullable( - gson.fromJson( + return gson.fromJson( src, BackupData::class.java - ) ) } } catch (e: IOException) { Log.e(TAG, "failed to read backup data!", e) - return Optional.empty() + return null } catch (e: JsonSyntaxException) { Log.e(TAG, "failed to read backup data!", e) - return Optional.empty() + return null } catch (e: JsonIOException) { Log.e(TAG, "failed to read backup data!", e) - return Optional.empty() + return null } } /** * restore a backup into the db * - * @param ctx the context to work in * @param data the data to restore * @param replaceExisting if true, existing entries are overwritten. if false, existing entries are not added */ - fun restoreBackup(ctx: Context, data: BackupData, replaceExisting: Boolean) { + fun restoreBackup(data: BackupData, replaceExisting: Boolean) { // check there are tracks to import if (data.tracks.isEmpty()) return // insert the tracks if (replaceExisting) - TracksDB.get(ctx).tracks().insertAll(data.tracks) + db.tracks().insertAll(data.tracks) else - TracksDB.get(ctx).tracks().insertAllNew(data.tracks) + db.tracks().insertAllNew(data.tracks) } } \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreViewModel.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreViewModel.kt index 597122d..5c186d0 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreViewModel.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreViewModel.kt @@ -65,15 +65,16 @@ class MoreViewModel(application: Application) : AndroidViewModel(application) { * @param file the file to import from */ fun importTracks(file: DocumentFile, parent: Activity) { + val backupHelper = BackupHelper(getApplication()) launchIO { // read the backup data - val backup = BackupHelper.readBackupData(getApplication(), file) - if (!backup.isPresent) { + val backup = backupHelper.readBackup(file) + if (backup == null) { launchMain { Toast.makeText( - getApplication(), - R.string.restore_toast_failed, - Toast.LENGTH_SHORT + getApplication(), + R.string.restore_toast_failed, + Toast.LENGTH_SHORT ).show() } return@launchIO @@ -82,39 +83,38 @@ class MoreViewModel(application: Application) : AndroidViewModel(application) { // show confirmation dialog launchMain { val replaceExisting = AtomicBoolean(false) - val tracksCount = backup.get().tracks.size + val tracksCount = backup.tracks.size AlertDialog.Builder(parent) - .setTitle( - getApplication().getString( - R.string.restore_dialog_title, - tracksCount + .setTitle( + getApplication().getString( + R.string.restore_dialog_title, + tracksCount + ) ) - ) - .setSingleChoiceItems( - R.array.restore_dialog_modes, - 0 - ) { _, mode -> replaceExisting.set(mode == 1) } - .setNegativeButton(R.string.restore_dialog_negative) { dialog, _ -> dialog.dismiss() } - .setPositiveButton(R.string.restore_dialog_positive) { _, _ -> - // restore the backup - Toast.makeText( - getApplication(), - getApplication().getString( - R.string.restore_toast_success, - tracksCount - ), - Toast.LENGTH_SHORT - ).show() - - launchIO { - BackupHelper.restoreBackup( - getApplication(), - backup.get(), - replaceExisting.get() - ) + .setSingleChoiceItems( + R.array.restore_dialog_modes, + 0 + ) { _, mode -> replaceExisting.set(mode == 1) } + .setNegativeButton(R.string.restore_dialog_negative) { dialog, _ -> dialog.dismiss() } + .setPositiveButton(R.string.restore_dialog_positive) { _, _ -> + // restore the backup + Toast.makeText( + getApplication(), + getApplication().getString( + R.string.restore_toast_success, + tracksCount + ), + Toast.LENGTH_SHORT + ).show() + + launchIO { + backupHelper.restoreBackup( + backup, + replaceExisting.get() + ) + } } - } - .show() + .show() } } } @@ -126,17 +126,13 @@ class MoreViewModel(application: Application) : AndroidViewModel(application) { */ fun exportTracks(file: DocumentFile) { launchIO { - val success = - BackupHelper.createBackup( - getApplication(), - file - ) + val success = BackupHelper(getApplication()).createBackup(file) if (!success) { launchMain { Toast.makeText( - getApplication(), - R.string.backup_toast_failed, - Toast.LENGTH_SHORT + getApplication(), + R.string.backup_toast_failed, + Toast.LENGTH_SHORT ).show() } } diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/backup/BackupHelperRoboTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/backup/BackupHelperRoboTest.kt new file mode 100644 index 0000000..00d7aa2 --- /dev/null +++ b/app/src/test/kotlin/io/github/shadow578/yodel/backup/BackupHelperRoboTest.kt @@ -0,0 +1,131 @@ +package io.github.shadow578.yodel.backup + +import androidx.documentfile.provider.DocumentFile +import androidx.room.Room +import io.github.shadow578.yodel.RoboTest +import io.github.shadow578.yodel.db.TracksDB +import io.github.shadow578.yodel.db.model.TrackInfo +import io.github.shadow578.yodel.db.model.TrackStatus +import io.github.shadow578.yodel.runArchSingleThreaded +import io.kotest.assertions.withClue +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.robolectric.Shadows +import org.robolectric.shadows.ShadowContentResolver +import java.io.File + +/** + * [BackupHelper] + */ +class BackupHelperRoboTest : RoboTest() { + lateinit var db: TracksDB + + lateinit var shadowContentResolver: ShadowContentResolver + + @Before + fun initDb() { + runArchSingleThreaded() + + // this uses the same config as TracksDB.get(), but in- memory and with allowMainThreadQueries() + db = Room.inMemoryDatabaseBuilder( + context, + TracksDB::class.java + ) + .allowMainThreadQueries() + .fallbackToDestructiveMigration() + .build() + + // insert some tracks + db.tracks().insertAll( + listOf( + TrackInfo("aabbcc", "A Title"), + TrackInfo("bbccdd", "B Title"), + TrackInfo("ccddee", "C Title", status = TrackStatus.Downloaded), + TrackInfo("ddeeff", "D Title", status = TrackStatus.Downloaded), + TrackInfo("eeffgg", "E Title", status = TrackStatus.Downloading) + ) + ) + + // we need a shadowed content resolver + shadowContentResolver = Shadows.shadowOf(context.contentResolver) + } + + @After + fun closeDb() { + db.close() + } + + @Test + fun shouldRestoreBackup() { + val backupHelper = BackupHelper(context, db) + val backupFile = File.createTempFile("test_backup", ".json", context.cacheDir) + val backupDocFile = DocumentFile.fromFile(backupFile) + + withClue("create the backup") + { + backupFile.outputStream().use { + shadowContentResolver.registerOutputStream(backupDocFile.uri, it) + + backupHelper.createBackup(backupDocFile) shouldBe true + backupDocFile.exists() shouldBe true + } + } + + withClue("clear database before restore test") + { + db.clearAllTables() + db.tracks().all.shouldBeEmpty() + } + + withClue("restore backup from file, replacing existing") + { + // insert one track into db to overwrite + db.tracks().insert(TrackInfo("bbccdd", "FooBar")) + + // read backup + backupFile.inputStream().use { + shadowContentResolver.registerInputStream(backupDocFile.uri, it) + + val backup = backupHelper.readBackup(backupDocFile) + backup.shouldNotBeNull() + + backupHelper.restoreBackup(backup, true) + db.tracks().all shouldContainExactlyInAnyOrder listOf( + TrackInfo("aabbcc", "A Title"), + TrackInfo("bbccdd", "B Title"), + TrackInfo("ccddee", "C Title"), + TrackInfo("ddeeff", "D Title"), + TrackInfo("eeffgg", "E Title") + ) + } + } + + withClue("restore backup from file, keeping existing") + { + // update one of the entries to be different than the backup + db.tracks().update(TrackInfo("bbccdd", "FooBar")) + + // read backup + backupFile.inputStream().use { + shadowContentResolver.registerInputStream(backupDocFile.uri, it) + + val backup = backupHelper.readBackup(backupDocFile) + backup.shouldNotBeNull() + + backupHelper.restoreBackup(backup, false) + db.tracks().all shouldContainExactlyInAnyOrder listOf( + TrackInfo("aabbcc", "A Title"), + TrackInfo("bbccdd", "FooBar"), + TrackInfo("ccddee", "C Title"), + TrackInfo("ddeeff", "D Title"), + TrackInfo("eeffgg", "E Title") + ) + } + } + } +} \ No newline at end of file From 3170eec24836014f1037e455c8159ed3e785cfb7 Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Sun, 8 Aug 2021 13:50:54 +0200 Subject: [PATCH 18/22] resolve various smaller lint warnings --- .github/gh-gradle.properties | 1 + app/build.gradle.kts | 2 +- app/src/main/AndroidManifest.xml | 8 ++++---- .../kotlin/io/github/shadow578/yodel/YodelApp.kt | 1 + .../shadow578/yodel/downloader/DownloadService.kt | 2 +- .../yodel/downloader/wrapper/MP3agicWrapper.kt | 12 +++++++----- .../yodel/downloader/wrapper/YoutubeDLWrapper.kt | 2 +- .../shadow578/yodel/ui/tracks/TracksFragment.kt | 2 +- .../shadow578/yodel/util/NotificationChannels.kt | 1 + .../github/shadow578/yodel/util/preferences/Prefs.kt | 4 +++- .../shadow578/yodel/util/storage/StorageHelper.kt | 12 ------------ .../shadow578/yodel/util/storage/StorageKey.kt | 4 ++-- app/src/main/res/layout/recycler_track_view.xml | 2 +- app/src/main/res/values-de-rDE/strings.xml | 4 ++-- app/src/main/res/values/strings.xml | 8 ++++---- .../shadow578/yodel/backup/BackupHelperRoboTest.kt | 4 ++-- .../shadow578/yodel/db/DBTypeConvertersTest.kt | 2 +- .../io/github/shadow578/yodel/db/TracksDBRoboTest.kt | 2 +- .../util/preferences/PreferenceWrapperRoboTest.kt | 6 +++--- 19 files changed, 37 insertions(+), 42 deletions(-) diff --git a/.github/gh-gradle.properties b/.github/gh-gradle.properties index 376477e..f9f3238 100644 --- a/.github/gh-gradle.properties +++ b/.github/gh-gradle.properties @@ -1,3 +1,4 @@ +# suppress inspection "UnusedProperty" for whole file org.gradle.daemon=false org.gradle.parallel=true org.gradle.jvmargs=-Xmx5g -XX:+UseParallelGC diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c352859..a53372f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -94,7 +94,7 @@ aboutLibraries { dependencies { // androidX implementation("androidx.core:core-ktx:1.6.0") - implementation("androidx.constraintlayout:constraintlayout:2.0.4") + implementation("androidx.constraintlayout:constraintlayout:2.1.0") implementation("androidx.appcompat:appcompat:1.3.1") implementation("androidx.lifecycle:lifecycle-service:2.3.1") implementation("androidx.legacy:legacy-support-v4:1.0.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d48f1e6..e2c74d4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,8 +5,6 @@ - - + android:theme="@style/Theme.SplashScreen" + android:exported="true"> @@ -33,7 +32,8 @@ - + diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/YodelApp.kt b/app/src/main/kotlin/io/github/shadow578/yodel/YodelApp.kt index 2edc235..d2b63eb 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/YodelApp.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/YodelApp.kt @@ -10,6 +10,7 @@ import io.github.shadow578.yodel.util.preferences.PreferenceWrapper /** * application class, for boilerplate init */ +@Suppress("unused") class YodelApp : Application() { override fun onCreate() { super.onCreate() diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt index 86bd188..d9248f2 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt @@ -395,7 +395,7 @@ class DownloaderService : LifecycleService() { Log.w(TAG, "failed to delete final file on copy fail") throw DownloaderException( - "error copying temp file (${files.audio}) to final destination (${finalFile.uri.toString()})", + "error copying temp file (${files.audio}) to final destination (${finalFile.uri})", e ) } diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/MP3agicWrapper.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/MP3agicWrapper.kt index 9930652..8df6020 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/MP3agicWrapper.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/MP3agicWrapper.kt @@ -18,10 +18,12 @@ import java.io.IOException class MP3agicWrapper( private val file: File ) { - /** - * tag for logging - */ - private val TAG = "MP3agicW" + companion object { + /** + * tag for logging + */ + private const val TAG = "MP3agicW" + } /** * the mp3agic file instance @@ -58,7 +60,7 @@ class MP3agicWrapper( * save the mp3 file, overwriting the original file * * @throws IOException if io operation fails - * @throws NotSupportedException if mp3agic failes to save the file (see [Mp3File.save]) + * @throws NotSupportedException if mp3agic fails to save the file (see [Mp3File.save]) */ @Throws(IOException::class, NotSupportedException::class) fun save() { diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapper.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapper.kt index adf4402..5a3cea7 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapper.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/wrapper/YoutubeDLWrapper.kt @@ -198,7 +198,7 @@ class YoutubeDLWrapper( * * @return self instance */ - fun printOutput(print: Boolean): YoutubeDLWrapper { + private fun printOutput(print: Boolean): YoutubeDLWrapper { printOutput = print return this } diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksFragment.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksFragment.kt index d3788d2..632b6ca 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksFragment.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksFragment.kt @@ -19,7 +19,7 @@ import io.github.shadow578.yodel.util.storage.decodeToUri /** * downloaded and downloading tracks UI */ -class TracksFragment() : BaseFragment() { +class TracksFragment : BaseFragment() { /** * the view binding instance */ diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/util/NotificationChannels.kt b/app/src/main/kotlin/io/github/shadow578/yodel/util/NotificationChannels.kt index d279d51..ba30335 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/util/NotificationChannels.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/util/NotificationChannels.kt @@ -22,6 +22,7 @@ enum class NotificationChannels( *

* only for use when testing stuff (and the actual channel is not setup yet) or for notifications that are normally not shown */ + @Suppress("unused") Default( R.string.channel_default_name, R.string.channel_default_description diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/util/preferences/Prefs.kt b/app/src/main/kotlin/io/github/shadow578/yodel/util/preferences/Prefs.kt index ae41d35..b067811 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/util/preferences/Prefs.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/util/preferences/Prefs.kt @@ -3,6 +3,8 @@ package io.github.shadow578.yodel.util.preferences import io.github.shadow578.yodel.downloader.TrackDownloadFormat import io.github.shadow578.yodel.LocaleOverride import io.github.shadow578.yodel.util.storage.StorageKey +import io.github.shadow578.yodel.downloader.wrapper.YoutubeDLWrapper +import io.github.shadow578.yodel.downloader.DownloaderService /** * app preferences storage @@ -27,7 +29,7 @@ object Prefs { ) /** - * download format [YoutubeDLWrapper] should use for future downloads. existing downloads are not affected + * download format [DownloaderService] should use for future downloads. existing downloads are not affected */ val DownloadFormat = PreferenceWrapper.create( TrackDownloadFormat::class.java, diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/util/storage/StorageHelper.kt b/app/src/main/kotlin/io/github/shadow578/yodel/util/storage/StorageHelper.kt index 979481c..f7430d7 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/util/storage/StorageHelper.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/util/storage/StorageHelper.kt @@ -8,7 +8,6 @@ import android.util.Log import androidx.documentfile.provider.DocumentFile import io.github.shadow578.yodel.util.unwrap import java.nio.charset.StandardCharsets -import java.util.* // region URI encode / decode /** @@ -82,22 +81,11 @@ fun StorageKey.decodeToFile(ctx: Context): DocumentFile? { //endregion // region storage framework wrapper -/** - * persist a file permission. see [android.content.ContentResolver.takePersistableUriPermission]. - * uses flags `Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION` - * - * @param ctx the context to persist the permission in - * @return the key for this file. can be read back using [.getPersistedFilePermission] - */ -fun DocumentFile.persistFilePermission(ctx: Context): StorageKey = - this.uri.persistFilePermission(ctx) - /** * persist a file permission. see [android.content.ContentResolver.takePersistableUriPermission]. * uses flags `Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION` * * @param ctx the context to persist the permission in - * @param uri the uri to take permission of * @return the key for this uri. can be read back using [.getPersistedFilePermission] */ fun Uri.persistFilePermission(ctx: Context): StorageKey { diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/util/storage/StorageKey.kt b/app/src/main/kotlin/io/github/shadow578/yodel/util/storage/StorageKey.kt index 3984735..e3c6da3 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/util/storage/StorageKey.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/util/storage/StorageKey.kt @@ -1,14 +1,14 @@ package io.github.shadow578.yodel.util.storage /** - * a storage key, used by [StorageHelper] + * a storage key, used by util/StorageHelper.kt */ data class StorageKey( val key: String ) { override fun toString(): String { - return key; + return key } companion object { diff --git a/app/src/main/res/layout/recycler_track_view.xml b/app/src/main/res/layout/recycler_track_view.xml index 6517a25..0f30b33 100644 --- a/app/src/main/res/layout/recycler_track_view.xml +++ b/app/src/main/res/layout/recycler_track_view.xml @@ -59,7 +59,7 @@ app:layout_constraintRight_toRightOf="@id/cover_art" app:layout_constraintTop_toTopOf="@id/cover_art" app:tint="@color/white" - tools:src="@drawable/ic_round_placeholder_24" /> + tools:src="@drawable/ic_round_timer_24" /> - + Von YouTube Herunterladen Leer, \\nwie meine Seele Unbekannt @@ -48,7 +48,7 @@ WAV Systemeinstellung folgen Wiederherstellen - eine App, um Musik von YouTube herunterzuladen + eine App, um Musik von YouTube herunterzuladen Sprache Download- Format \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0e8f24c..b82bc4d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - + Yodel Download from YouTube @@ -76,7 +76,7 @@ WAV - true - true - a app for downloading music from YouTube + true + true + a app for downloading music from YouTube \ No newline at end of file diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/backup/BackupHelperRoboTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/backup/BackupHelperRoboTest.kt index 00d7aa2..ebed2df 100644 --- a/app/src/test/kotlin/io/github/shadow578/yodel/backup/BackupHelperRoboTest.kt +++ b/app/src/test/kotlin/io/github/shadow578/yodel/backup/BackupHelperRoboTest.kt @@ -23,9 +23,9 @@ import java.io.File * [BackupHelper] */ class BackupHelperRoboTest : RoboTest() { - lateinit var db: TracksDB + private lateinit var db: TracksDB - lateinit var shadowContentResolver: ShadowContentResolver + private lateinit var shadowContentResolver: ShadowContentResolver @Before fun initDb() { diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/db/DBTypeConvertersTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/db/DBTypeConvertersTest.kt index 6484111..d008090 100644 --- a/app/src/test/kotlin/io/github/shadow578/yodel/db/DBTypeConvertersTest.kt +++ b/app/src/test/kotlin/io/github/shadow578/yodel/db/DBTypeConvertersTest.kt @@ -33,7 +33,7 @@ class DBTypeConvertersTest { conv.fromStorageKey(null).shouldBeNull() conv.toStorageKey(null).shouldBeNull() - // emtpy string + // empty string conv.toStorageKey("") shouldBe StorageKey.EMPTY } diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/db/TracksDBRoboTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/db/TracksDBRoboTest.kt index 5c131f5..f7a9e84 100644 --- a/app/src/test/kotlin/io/github/shadow578/yodel/db/TracksDBRoboTest.kt +++ b/app/src/test/kotlin/io/github/shadow578/yodel/db/TracksDBRoboTest.kt @@ -12,7 +12,7 @@ import org.junit.* * [TracksDB] */ class TracksDBRoboTest : RoboTest() { - lateinit var db: TracksDB + private lateinit var db: TracksDB @Before fun initDb() { diff --git a/app/src/test/kotlin/io/github/shadow578/yodel/util/preferences/PreferenceWrapperRoboTest.kt b/app/src/test/kotlin/io/github/shadow578/yodel/util/preferences/PreferenceWrapperRoboTest.kt index b13851f..66dcce3 100644 --- a/app/src/test/kotlin/io/github/shadow578/yodel/util/preferences/PreferenceWrapperRoboTest.kt +++ b/app/src/test/kotlin/io/github/shadow578/yodel/util/preferences/PreferenceWrapperRoboTest.kt @@ -9,9 +9,9 @@ import org.junit.* * robolectric tests for [PreferenceWrapper] */ class PreferenceWrapperRoboTest : RoboTest() { - lateinit var stringPref: PreferenceWrapper - lateinit var booleanPref: PreferenceWrapper - lateinit var objectPref: PreferenceWrapper + private lateinit var stringPref: PreferenceWrapper + private lateinit var booleanPref: PreferenceWrapper + private lateinit var objectPref: PreferenceWrapper @Before fun setupPrefs() { From 56d3cd984281301fc9068509ef514b2ada008105 Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Sun, 8 Aug 2021 13:58:33 +0200 Subject: [PATCH 19/22] handle Bitmap.CompressFormat.WEBP deprecation --- .../yodel/downloader/DownloadService.kt | 145 ++++++++++-------- 1 file changed, 79 insertions(+), 66 deletions(-) diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt index d9248f2..4f008ce 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/downloader/DownloadService.kt @@ -2,25 +2,37 @@ package io.github.shadow578.yodel.downloader import android.app.Notification import android.content.Context -import android.graphics.* +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Build import android.util.Log import android.widget.Toast import androidx.annotation.StringRes -import androidx.core.app.* +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.LifecycleService -import com.google.gson.* -import com.mpatric.mp3agic.* +import com.google.gson.Gson +import com.google.gson.JsonIOException +import com.google.gson.JsonSyntaxException +import com.mpatric.mp3agic.InvalidDataException +import com.mpatric.mp3agic.NotSupportedException +import com.mpatric.mp3agic.UnsupportedTagException import io.github.shadow578.yodel.R import io.github.shadow578.yodel.db.TracksDB -import io.github.shadow578.yodel.db.model.* -import io.github.shadow578.yodel.downloader.wrapper.* +import io.github.shadow578.yodel.db.model.TrackInfo +import io.github.shadow578.yodel.db.model.TrackStatus +import io.github.shadow578.yodel.downloader.wrapper.MP3agicWrapper +import io.github.shadow578.yodel.downloader.wrapper.YoutubeDLWrapper import io.github.shadow578.yodel.util.* import io.github.shadow578.yodel.util.preferences.Prefs -import io.github.shadow578.yodel.util.storage.* +import io.github.shadow578.yodel.util.storage.StorageKey +import io.github.shadow578.yodel.util.storage.encodeToKey +import io.github.shadow578.yodel.util.storage.getPersistedFilePermission import java.io.* import java.util.* -import java.util.concurrent.* +import java.util.concurrent.BlockingQueue +import java.util.concurrent.LinkedBlockingQueue import kotlin.math.floor /** @@ -78,9 +90,9 @@ class DownloaderService : LifecycleService() { // ensure downloads are accessible if (!checkDownloadsDirSet()) { Toast.makeText( - this, - "Downloads directory not accessible, stopping Downloader!", - Toast.LENGTH_LONG + this, + "Downloads directory not accessible, stopping Downloader!", + Toast.LENGTH_LONG ).show() Log.i(TAG, "downloads dir not accessible, stopping service") stopSelf() @@ -90,18 +102,18 @@ class DownloaderService : LifecycleService() { // init db and observe changes to pending tracks Log.i(TAG, "start observing pending tracks...") TracksDB.get(this).tracks().observePending().observe(this, - { pendingTracks: List -> - Log.i(TAG, String.format("pendingTracks update! size= ${pendingTracks.size}")) + { pendingTracks: List -> + Log.i(TAG, String.format("pendingTracks update! size= ${pendingTracks.size}")) - // enqueue all that are not scheduled already - for (track in pendingTracks) { - // ignore if track not pending - if (scheduledDownloads.contains(track) || track.status != TrackStatus.DownloadPending) continue + // enqueue all that are not scheduled already + for (track in pendingTracks) { + // ignore if track not pending + if (scheduledDownloads.contains(track) || track.status != TrackStatus.DownloadPending) continue - //enqueue the track - scheduledDownloads.put(track) - } - }) + //enqueue the track + scheduledDownloads.put(track) + } + }) // start downloader thread as daemon downloadThread.name = "io.github.shadow578.yodel.downloader.DOWNLOAD_THREAD" @@ -204,10 +216,10 @@ class DownloaderService : LifecycleService() { return try { // create session updateNotification( - createStatusNotification( - track, - R.string.dl_status_starting_download - ) + createStatusNotification( + track, + R.string.dl_status_starting_download + ) ) val session = createSession(track, format) files = createTempFiles(track, format) @@ -226,9 +238,9 @@ class DownloaderService : LifecycleService() { writeID3Tag(track, files) } catch (e: DownloaderException) { Log.e( - TAG, - "failed to write id3v2 tags of ${track.id}! (not fatal, the rest of the download was successful)", - e + TAG, + "failed to write id3v2 tags of ${track.id}! (not fatal, the rest of the download was successful)", + e ) } @@ -242,9 +254,9 @@ class DownloaderService : LifecycleService() { copyCoverToFinal(track, files) } catch (e: DownloaderException) { Log.e( - TAG, - "failed to copy cover of ${track.id}! (not fatal, the rest of the download was successful)", - e + TAG, + "failed to copy cover of ${track.id}! (not fatal, the rest of the download was successful)", + e ) } true @@ -271,8 +283,8 @@ class DownloaderService : LifecycleService() { @Throws(DownloaderException::class) private fun createSession(track: TrackInfo, format: TrackDownloadFormat): YoutubeDLWrapper { val session = YoutubeDLWrapper(resolveVideoUrl(track)) - .cacheDir(downloadCacheDirectory) - .audioOnly(format.fileExtension) + .cacheDir(downloadCacheDirectory) + .audioOnly(format.fileExtension) // enable ssl fix if (Prefs.EnableSSLFix.get()) @@ -307,14 +319,14 @@ class DownloaderService : LifecycleService() { // download val downloadResponse = session.output(files.audio) - //.overwriteExisting() - .writeMetadata() - .writeThumbnail() - .download({ progress: Float, etaInSeconds: Long -> - updateNotification( - createProgressNotification(track, progress / 100.0, etaInSeconds) - ) - }, YOUTUBE_DL_RETRIES) + //.overwriteExisting() + .writeMetadata() + .writeThumbnail() + .download({ progress: Float, etaInSeconds: Long -> + updateNotification( + createProgressNotification(track, progress / 100.0, etaInSeconds) + ) + }, YOUTUBE_DL_RETRIES) if (downloadResponse == null || !files.audio.exists() || !files.metadataJson.exists()) throw DownloaderException("youtube-dl download failed!") } @@ -337,8 +349,8 @@ class DownloaderService : LifecycleService() { try { FileReader(files.metadataJson).use { reader -> metadata = gson.fromJson( - reader, - TrackMetadata::class.java + reader, + TrackMetadata::class.java ) } } catch (e: IOException) { @@ -374,11 +386,11 @@ class DownloaderService : LifecycleService() { // find root folder for saving downloaded tracks to // find using storage framework, and only allow persisted folders we can write to val downloadRoot = downloadsDirectory - ?: throw DownloaderException("failed to find downloads folder") + ?: throw DownloaderException("failed to find downloads folder") // create file to write the track to val finalFile = - downloadRoot.createFile(format.mimetype, track.title + "." + format.fileExtension) + downloadRoot.createFile(format.mimetype, track.title + "." + format.fileExtension) if (finalFile == null || !finalFile.canWrite()) throw DownloaderException("Could not create final output file!") @@ -395,8 +407,8 @@ class DownloaderService : LifecycleService() { Log.w(TAG, "failed to delete final file on copy fail") throw DownloaderException( - "error copying temp file (${files.audio}) to final destination (${finalFile.uri})", - e + "error copying temp file (${files.audio}) to final destination (${finalFile.uri})", + e ) } @@ -428,8 +440,9 @@ class DownloaderService : LifecycleService() { try { FileInputStream(thumbnail).use { src -> FileOutputStream(coverFile).use { out -> + val format = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Bitmap.CompressFormat.WEBP_LOSSLESS else Bitmap.CompressFormat.WEBP val cover = BitmapFactory.decodeStream(src) - cover.compress(Bitmap.CompressFormat.WEBP, 100, out) + cover.compress(format, 100, out) cover.recycle() } } @@ -454,8 +467,8 @@ class DownloaderService : LifecycleService() { // clear all previous id3 tags, and create a new & empty one val mp3Wrapper = MP3agicWrapper(files.audio) val tag = mp3Wrapper - .clearAllTags() - .tag + .clearAllTags() + .tag // write basic metadata (title, artist, album, ...) tag.title = track.title @@ -588,15 +601,15 @@ class DownloaderService : LifecycleService() { * @return the progress notification */ private fun createProgressNotification( - track: TrackInfo, - progress: Double, - eta: Long + track: TrackInfo, + progress: Double, + eta: Long ): Notification { return baseNotification - .setContentTitle(track.title) - .setSubText(getString(R.string.dl_notification_subtext, eta.secondsToTimeString())) - .setProgress(100, floor(progress * 100).toInt(), false) - .build() + .setContentTitle(track.title) + .setSubText(getString(R.string.dl_notification_subtext, eta.secondsToTimeString())) + .setProgress(100, floor(progress * 100).toInt(), false) + .build() } /** @@ -607,14 +620,14 @@ class DownloaderService : LifecycleService() { * @return the status notification */ private fun createStatusNotification( - track: TrackInfo, - @StringRes statusRes: Int + track: TrackInfo, + @StringRes statusRes: Int ): Notification { return baseNotification - .setContentTitle(track.title) - .setSubText(getString(statusRes)) - .setProgress(1, 0, true) - .build() + .setContentTitle(track.title) + .setSubText(getString(statusRes)) + .setProgress(1, 0, true) + .build() } /** @@ -624,7 +637,7 @@ class DownloaderService : LifecycleService() { */ //endregion private val baseNotification: NotificationCompat.Builder get() = NotificationCompat.Builder(this, NotificationChannels.DownloadProgress.id) - .setSmallIcon(R.drawable.ic_launcher_foreground) - .setShowWhen(false) - .setOnlyAlertOnce(true) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setShowWhen(false) + .setOnlyAlertOnce(true) } \ No newline at end of file From b70136439a3723a148fc5bb81d75ef4eb0425a24 Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Sun, 8 Aug 2021 14:48:01 +0200 Subject: [PATCH 20/22] use DiffUtil for track updates --- .../shadow578/yodel/db/model/TrackInfo.kt | 145 +++++++++++------- .../yodel/ui/tracks/TracksAdapter.kt | 103 ++++++++----- 2 files changed, 153 insertions(+), 95 deletions(-) diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/db/model/TrackInfo.kt b/app/src/main/kotlin/io/github/shadow578/yodel/db/model/TrackInfo.kt index 763481e..5e363d2 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/db/model/TrackInfo.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/db/model/TrackInfo.kt @@ -1,6 +1,9 @@ package io.github.shadow578.yodel.db.model -import androidx.room.* +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey import io.github.shadow578.yodel.util.storage.StorageKey import java.time.LocalDate @@ -8,73 +11,77 @@ import java.time.LocalDate * information about a track */ @Entity( - tableName = "tracks", - indices = [ - Index("first_added_at") - ] + tableName = "tracks", + indices = [ + Index("first_added_at") + ] ) data class TrackInfo( - /** - * the youtube id of the track - */ - @ColumnInfo(name = "id") - @PrimaryKey - val id: String, + /** + * the youtube id of the track + */ + @ColumnInfo(name = "id") + @PrimaryKey + val id: String, - /** - * the title of the track - */ - @ColumnInfo(name = "track_title") - var title: String, + /** + * the title of the track + */ + @ColumnInfo(name = "track_title") + var title: String, - /** - * the name of the artist - */ - @ColumnInfo(name = "artist_name") - var artist: String? = null, + /** + * the name of the artist + */ + @ColumnInfo(name = "artist_name") + var artist: String? = null, - /** - * the day the track was released / uploaded - */ - @ColumnInfo(name = "release_date") - var releaseDate: LocalDate? = null, + /** + * the day the track was released / uploaded + */ + @ColumnInfo(name = "release_date") + var releaseDate: LocalDate? = null, - /** - * duration of the track, in seconds - */ - @ColumnInfo(name = "duration") - var duration: Long? = null, + /** + * duration of the track, in seconds + */ + @ColumnInfo(name = "duration") + var duration: Long? = null, - /** - * the album name, if this track is part of one - */ - @ColumnInfo(name = "album_name") - var albumName: String? = null, + /** + * the album name, if this track is part of one + */ + @ColumnInfo(name = "album_name") + var albumName: String? = null, - /** - * the key of the file this track was downloaded to - */ - @ColumnInfo(name = "audio_file_key") - var audioFileKey: StorageKey = StorageKey.EMPTY, + /** + * the key of the file this track was downloaded to + */ + @ColumnInfo(name = "audio_file_key") + var audioFileKey: StorageKey = StorageKey.EMPTY, - /** - * the key of the track cover image file - */ - @ColumnInfo(name = "cover_file_key") - var coverKey: StorageKey = StorageKey.EMPTY, + /** + * the key of the track cover image file + */ + @ColumnInfo(name = "cover_file_key") + var coverKey: StorageKey = StorageKey.EMPTY, - /** - * is this track fully downloaded? - */ - @ColumnInfo(name = "status") - var status: TrackStatus = TrackStatus.DownloadPending, + /** + * is this track fully downloaded? + */ + @ColumnInfo(name = "status") + var status: TrackStatus = TrackStatus.DownloadPending, + /** + * when this track was first added. millis timestamp, from [System.currentTimeMillis] + */ + @ColumnInfo(name = "first_added_at") + val firstAddedAt: Long = System.currentTimeMillis() +) { /** - * when this track was first added. millis timestamp, from [System.currentTimeMillis] + * default equals operation. this only checks for the [id]. + * if you want to compare the actual contents of the object, use [equalsContent] */ - @ColumnInfo(name = "first_added_at") - val firstAddedAt: Long = System.currentTimeMillis() -) { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -86,7 +93,35 @@ data class TrackInfo( return true } + /** + * default hashcode implementation. only used [id] + */ override fun hashCode(): Int { return id.hashCode() } + + /** + * equals operation that takes all fields into account + */ + fun equalsContent(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as TrackInfo + + if (id != other.id) return false + if (title != other.title) return false + if (artist != other.artist) return false + if (releaseDate != other.releaseDate) return false + if (duration != other.duration) return false + if (albumName != other.albumName) return false + if (audioFileKey != other.audioFileKey) return false + if (coverKey != other.coverKey) return false + if (status != other.status) return false + if (firstAddedAt != other.firstAddedAt) return false + + return true + } + + } \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksAdapter.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksAdapter.kt index 2e6c19c..28139ea 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksAdapter.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/tracks/TracksAdapter.kt @@ -4,6 +4,7 @@ import android.view.* import androidx.annotation.DrawableRes import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import io.github.shadow578.yodel.R @@ -13,60 +14,82 @@ import io.github.shadow578.yodel.util.* import io.github.shadow578.yodel.util.storage.decodeToUri import kotlinx.coroutines.delay import java.util.* -import kotlin.collections.List import kotlin.collections.set /** * recyclerview adapter for tracks livedata */ class TracksAdapter( - owner: LifecycleOwner, - tracks: LiveData>, - private val clickListener: ItemListener, - private val reDownloadListener: ItemListener + owner: LifecycleOwner, + tracks: LiveData>, + private val clickListener: ItemListener, + private val reDownloadListener: ItemListener ) : RecyclerView.Adapter() { - init { - tracks.observe(owner, { trackInfoList: List -> - this.tracks = trackInfoList - notifyDataSetChanged() + tracks.observe(owner, { newTracks: List -> + // calculate difference + val difference = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize(): Int { + return currentTracks.size + } + + override fun getNewListSize(): Int { + return newTracks.size + } + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return currentTracks[oldItemPosition].id == newTracks[newItemPosition].id + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return currentTracks[oldItemPosition].equalsContent(newTracks[newItemPosition]) + } + }) + + // update data and apply the changes + currentTracks = newTracks + difference.dispatchUpdatesTo(this) }) } - private var tracks: List = ArrayList() + /** + * current tracks list displayed + */ + private var currentTracks: List = listOf() /** * items that should be removed later. * key is item position, value if remove was aborted */ private val itemsToDelete = HashMap() + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { return Holder( - RecyclerTrackViewBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) + RecyclerTrackViewBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) ) } override fun onBindViewHolder(holder: Holder, position: Int) { - val track = tracks[position] + val track = currentTracks[position] // cover val coverUri = track.coverKey.decodeToUri() if (coverUri != null) { // load cover from fs using glide Glide.with(holder.b.coverArt) - .load(coverUri) - .placeholder(R.drawable.ic_splash_foreground) - .fallback(R.drawable.ic_splash_foreground) - .into(holder.b.coverArt) + .load(coverUri) + .placeholder(R.drawable.ic_splash_foreground) + .fallback(R.drawable.ic_splash_foreground) + .into(holder.b.coverArt) } else { // load fallback image Glide.with(holder.b.coverArt) - .load(R.drawable.ic_splash_foreground) - .into(holder.b.coverArt) + .load(R.drawable.ic_splash_foreground) + .into(holder.b.coverArt) } // title @@ -74,19 +97,19 @@ class TracksAdapter( // build and set artist + album val albumAndArtist: String? = - if (track.artist != null && track.albumName != null) { - holder.b.root.context.getString( - R.string.tracks_artist_and_album, - track.artist, + if (track.artist != null && track.albumName != null) { + holder.b.root.context.getString( + R.string.tracks_artist_and_album, + track.artist, + track.albumName + ) + } else if (track.artist != null) { + track.artist + } else if (track.albumName != null) { track.albumName - ) - } else if (track.artist != null) { - track.artist - } else if (track.albumName != null) { - track.albumName - } else { - null - } + } else { + null + } holder.b.albumAndArtist.text = albumAndArtist ?: "" // status icon @@ -141,7 +164,7 @@ class TracksAdapter( */ fun deleteLater(item: Holder, deleteCallback: ItemListener) { val position = item.bindingAdapterPosition - val track = tracks[position] + val track = currentTracks[position] // mark as to delete itemsToDelete[position] = true @@ -168,17 +191,17 @@ class TracksAdapter( } override fun getItemCount(): Int { - return tracks.size + return currentTracks.size } /** * a view holder for the items of this adapter */ class Holder( - /** - * view binding of the view this holder holds - */ - val b: RecyclerTrackViewBinding + /** + * view binding of the view this holder holds + */ + val b: RecyclerTrackViewBinding ) : RecyclerView.ViewHolder(b.root) { /** From 27f01dceb5a388e2b3dfbaa74e59f77f1c73de9f Mon Sep 17 00:00:00 2001 From: shadow578 <52449218+shadow578@users.noreply.github.com> Date: Sun, 8 Aug 2021 15:19:46 +0200 Subject: [PATCH 21/22] show version in more --- .../shadow578/yodel/ui/more/MoreFragment.kt | 3 +++ app/src/main/res/layout/fragment_more.xml | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreFragment.kt b/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreFragment.kt index a0177bf..2454952 100644 --- a/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreFragment.kt +++ b/app/src/main/kotlin/io/github/shadow578/yodel/ui/more/MoreFragment.kt @@ -103,6 +103,9 @@ class MoreFragment : BaseFragment() { MoreViewModel::class.java ) + // show app version + b.appVersion.text = BuildConfig.VERSION_NAME + // about button b.about.setOnClickListener { model.openAboutPage(requireActivity()) } diff --git a/app/src/main/res/layout/fragment_more.xml b/app/src/main/res/layout/fragment_more.xml index 4ea3232..bd41f7a 100644 --- a/app/src/main/res/layout/fragment_more.xml +++ b/app/src/main/res/layout/fragment_more.xml @@ -24,15 +24,30 @@ app:layout_constraintTop_toTopOf="parent" app:srcCompat="@drawable/ic_splash_foreground" /> - + + + + Date: Sun, 8 Aug 2021 15:20:30 +0200 Subject: [PATCH 22/22] bump version --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a53372f..890bc88 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,8 +24,8 @@ android { minSdk = 23 targetSdk = 30 compileSdk = 30 - versionCode = 1 - versionName = "1.0" + versionCode = 2 + versionName = "1.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" kapt {