diff --git a/dependencies.gradle b/dependencies.gradle index 1abb8e94..42ae817e 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -7,7 +7,7 @@ ext.versions = [ navigation : "2.3.5", gradlePlugin : "4.2.2", - androidxcore : "1.5.0", + androidxcore : "1.6.0", ktlint : "0.37.1", ktlintGradle : "10.2.0", detekt : "1.18.1", @@ -23,7 +23,7 @@ ext.versions = [ room : "2.3.0", kotlin : "1.5.31", kotlinxCoroutines : "1.5.2-native-mt", - viewModelKtx : "2.2.0", + viewModelKtx : "2.3.0", retrofit : "2.6.4", jackson : "2.9.5", @@ -35,7 +35,7 @@ ext.versions = [ junit4 : "4.13.2", junit5 : "5.8.1", mockito : "4.0.0", - mockitoKotlin : "3.2.0", + mockitoKotlin : "4.0.0", kluent : "1.68", apacheCodecs : "1.15", robolectric : "4.6.1", diff --git a/ultrasonic/lint-baseline.xml b/ultrasonic/lint-baseline.xml index 63eb8137..fef81695 100644 --- a/ultrasonic/lint-baseline.xml +++ b/ultrasonic/lint-baseline.xml @@ -1,6 +1,20 @@ + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1) { @@ -298,8 +299,8 @@ class DownloadFile( Timber.w(all, "Failed to download '%s'.", song) } } finally { - Util.close(inputStream) - Util.close(outputStream) + inputStream.safeClose() + outputStream.safeClose() CacheCleaner().cleanSpace() downloader.checkDownloads() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt index c214776d..401d8e96 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt @@ -39,7 +39,7 @@ import org.moire.ultrasonic.domain.Share import org.moire.ultrasonic.domain.UserInfo import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.FileUtil -import org.moire.ultrasonic.util.Util +import org.moire.ultrasonic.util.Util.safeClose import timber.log.Timber import java.io.FileReader import java.io.FileWriter @@ -215,8 +215,8 @@ class OfflineMusicService : MusicService, KoinComponent { } playlist } finally { - Util.close(buffer) - Util.close(reader) + buffer.safeClose() + reader.safeClose() } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt index e89f8271..4f6f0598 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt @@ -1,6 +1,5 @@ package org.moire.ultrasonic.util -import android.net.Uri import android.os.AsyncTask import android.os.StatFs import android.system.Os @@ -16,7 +15,7 @@ import org.moire.ultrasonic.util.FileUtil.getPlaylistFile import org.moire.ultrasonic.util.FileUtil.listFiles import org.moire.ultrasonic.util.FileUtil.musicDirectory import org.moire.ultrasonic.util.Settings.cacheSizeMB -import org.moire.ultrasonic.util.Util.delete +import org.moire.ultrasonic.util.FileUtil.delete import org.moire.ultrasonic.util.Util.formatBytes import timber.log.Timber @@ -60,9 +59,12 @@ class CacheCleaner { Thread.currentThread().name = "BackgroundCleanup" val files: MutableList = ArrayList() val dirs: MutableList = ArrayList() + findCandidatesForDeletion(musicDirectory, files, dirs) + sortByAscendingModificationTime(files) val filesToNotDelete = findFilesToNotDelete() + deleteFiles(files, filesToNotDelete, getMinimumDelete(files), true) deleteEmptyDirs(dirs, filesToNotDelete) } catch (all: RuntimeException) { @@ -76,10 +78,14 @@ class CacheCleaner { override fun doInBackground(vararg params: Void?): Void? { try { Thread.currentThread().name = "BackgroundSpaceCleanup" + val files: MutableList = ArrayList() val dirs: MutableList = ArrayList() + findCandidatesForDeletion(musicDirectory, files, dirs) + val bytesToDelete = getMinimumDelete(files) + if (bytesToDelete > 0L) { sortByAscendingModificationTime(files) val filesToNotDelete = findFilesToNotDelete() @@ -99,12 +105,15 @@ class CacheCleaner { ActiveServerProvider::class.java ) Thread.currentThread().name = "BackgroundPlaylistsCleanup" + val server = activeServerProvider.value.getActiveServer().name val playlistFiles = listFiles(getPlaylistDirectory(server)) val playlists = params[0] + for ((_, name) in playlists) { playlistFiles.remove(getPlaylistFile(server, name)) } + for (playlist in playlistFiles) { playlist.delete() } @@ -119,9 +128,8 @@ class CacheCleaner { private const val MIN_FREE_SPACE = 500 * 1024L * 1024L private fun deleteEmptyDirs(dirs: Iterable, doNotDelete: Collection) { for (dir in dirs) { - if (doNotDelete.contains(dir.getPath())) { - continue - } + if (doNotDelete.contains(dir.getPath())) continue + var children = dir.listFiles() if (children != null) { // No songs left in the folder @@ -140,11 +148,11 @@ class CacheCleaner { } private fun getMinimumDelete(files: List): Long { - if (files.isEmpty()) { - return 0L - } + if (files.isEmpty()) return 0L + val cacheSizeBytes = cacheSizeMB * 1024L * 1024L var bytesUsedBySubsonic = 0L + for (file in files) { bytesUsedBySubsonic += file.length() } @@ -154,6 +162,7 @@ class CacheCleaner { val minFsAvailability: Long val bytesTotalFs: Long val bytesAvailableFs: Long + if (files[0].isRawFile()) { val stat = StatFs(files[0].getRawFilePath()) bytesTotalFs = stat.blockCountLong * stat.blockSizeLong @@ -169,6 +178,7 @@ class CacheCleaner { minFsAvailability = bytesTotalFs - MIN_FREE_SPACE descriptor.close() } + val bytesToDeleteCacheLimit = (bytesUsedBySubsonic - cacheSizeBytes).coerceAtLeast(0L) val bytesToDeleteFsLimit = (bytesUsedFs - minFsAvailability).coerceAtLeast(0L) val bytesToDelete = bytesToDeleteCacheLimit.coerceAtLeast(bytesToDeleteFsLimit) @@ -181,6 +191,7 @@ class CacheCleaner { Timber.i("Cache limit : %s", formatBytes(cacheSizeBytes)) Timber.i("Cache size before : %s", formatBytes(bytesUsedBySubsonic)) Timber.i("Minimum to delete : %s", formatBytes(bytesToDelete)) + return bytesToDelete } @@ -203,6 +214,7 @@ class CacheCleaner { return } var bytesDeleted = 0L + for (file in files) { if (!deletePartials && bytesDeleted > bytesToDelete) break if (bytesToDelete > bytesDeleted || deletePartials && isPartial(file)) { @@ -244,10 +256,12 @@ class CacheCleaner { val downloader = inject( Downloader::class.java ) + for (downloadFile in downloader.value.all) { filesToNotDelete.add(downloadFile.partialFile) filesToNotDelete.add(downloadFile.completeOrSaveFile) } + filesToNotDelete.add(musicDirectory.getPath()) return filesToNotDelete } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt index 83534321..da0047db 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt @@ -26,9 +26,11 @@ import java.util.TreeSet import java.util.regex.Pattern import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.util.Util.safeClose import timber.log.Timber import java.io.File +@Suppress("TooManyFunctions") object FileUtil { private val FILE_SYSTEM_UNSAFE = arrayOf("/", "\\", "..", ":", "\"", "?", "*", "<", ">", "|") @@ -423,7 +425,7 @@ object FileUtil { Timber.w("Failed to serialize object to %s", file) false } finally { - Util.close(out) + out.safeClose() } } @@ -445,7 +447,7 @@ object FileUtil { Timber.w(all, "Failed to deserialize object from %s", file) null } finally { - Util.close(inStream) + inStream.safeClose() } } @@ -473,8 +475,39 @@ object FileUtil { Timber.w("Failed to save playlist: %s", name) throw e } finally { - bw.close() - fw.close() + bw.safeClose() + fw.safeClose() } } + + @JvmStatic + @Throws(IOException::class) + fun renameFile(from: String, to: String) { + StorageFile.rename(from, to) + } + + @JvmStatic + fun delete(file: File?): Boolean { + if (file != null && file.exists()) { + if (!file.delete()) { + Timber.w("Failed to delete file %s", file) + return false + } + Timber.i("Deleted file %s", file) + } + return true + } + + @JvmStatic + fun delete(file: String?): Boolean { + if (file != null && StorageFile.isPathExists(file)) { + if (!StorageFile.getFromPath(file).delete()) { + Timber.w("Failed to delete file %s", file) + return false + } + Timber.i("Deleted file %s", file) + } + return true + } + } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt index 7a45b123..d7c8545f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt @@ -9,7 +9,6 @@ package org.moire.ultrasonic.util import android.content.Context import android.content.SharedPreferences -import android.net.ConnectivityManager import android.os.Build import androidx.preference.PreferenceManager import java.util.regex.Pattern @@ -70,17 +69,21 @@ object Settings { @JvmStatic val maxBitRate: Int get() { - val manager = Util.getConnectivityManager() - val networkInfo = manager.activeNetworkInfo ?: return 0 - val wifi = networkInfo.type == ConnectivityManager.TYPE_WIFI - val preferences = preferences - return preferences.getString( - if (wifi) Constants.PREFERENCES_KEY_MAX_BITRATE_WIFI - else Constants.PREFERENCES_KEY_MAX_BITRATE_MOBILE, - "0" - )!!.toInt() + val network = Util.networkInfo() + + if (!network.connected) return 0 + + if (network.unmetered) { + return maxWifiBitRate + } else { + return maxMobileBitRate + } } + private var maxWifiBitRate by StringIntSetting(Constants.PREFERENCES_KEY_MAX_BITRATE_WIFI) + + private var maxMobileBitRate by StringIntSetting(Constants.PREFERENCES_KEY_MAX_BITRATE_MOBILE) + @JvmStatic val preloadCount: Int get() { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SubsonicUncaughtExceptionHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SubsonicUncaughtExceptionHandler.kt index 4231c34c..4e6d1834 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SubsonicUncaughtExceptionHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SubsonicUncaughtExceptionHandler.kt @@ -3,6 +3,7 @@ package org.moire.ultrasonic.util import android.content.Context import android.os.Build import java.io.PrintWriter +import org.moire.ultrasonic.util.Util.safeClose import timber.log.Timber import java.io.File @@ -34,7 +35,7 @@ class SubsonicUncaughtExceptionHandler( } catch (x: Throwable) { Timber.e(x, "Failed to write stack trace to %s", file) } finally { - Util.close(printWriter) + printWriter.safeClose() defaultHandler?.uncaughtException(thread, throwable) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index e2d7b259..52f51bd5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -23,9 +23,13 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.media.MediaScannerConnection import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET +import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED import android.net.Uri import android.net.wifi.WifiManager import android.net.wifi.WifiManager.WifiLock +import android.os.Build import android.os.Bundle import android.os.Environment import android.os.Parcelable @@ -40,6 +44,7 @@ import androidx.annotation.AnyRes import androidx.media.utils.MediaConstants import java.io.Closeable import java.io.IOException +import java.io.File import java.io.UnsupportedEncodingException import java.security.MessageDigest import java.text.DecimalFormat @@ -57,7 +62,6 @@ import org.moire.ultrasonic.domain.PlayerState import org.moire.ultrasonic.domain.SearchResult import org.moire.ultrasonic.service.DownloadFile import timber.log.Timber -import java.io.File private const val LINE_LENGTH = 60 private const val DEGRADE_PRECISION_AFTER = 10 @@ -98,7 +102,7 @@ object Util { when (Settings.theme.lowercase()) { Constants.PREFERENCES_KEY_THEME_DARK, "fullscreen" -> { - context!!.setTheme(R.style.UltrasonicTheme) + context!!.setTheme(R.style.UltrasonicTheme_Dark) } Constants.PREFERENCES_KEY_THEME_BLACK -> { context!!.setTheme(R.style.UltrasonicTheme_Black) @@ -110,44 +114,6 @@ object Util { } } - @JvmStatic - @Throws(IOException::class) - fun renameFile(from: String, to: String) { - StorageFile.rename(from, to) - } - - @JvmStatic - fun close(closeable: Closeable?) { - try { - closeable?.close() - } catch (_: Throwable) { - // Ignored - } - } - - @JvmStatic - fun delete(file: String?): Boolean { - if (file != null && StorageFile.isPathExists(file)) { - if (!StorageFile.getFromPath(file).delete()) { - Timber.w("Failed to delete file %s", file) - return false - } - Timber.i("Deleted file %s", file) - } - return true - } - - fun delete(file: File?): Boolean { - if (file != null && file.exists()) { - if (!file.delete()) { - Timber.w("Failed to delete file %s", file) - return false - } - Timber.i("Deleted file %s", file) - } - return true - } - @JvmStatic @JvmOverloads fun toast(context: Context?, messageId: Int, shortDuration: Boolean = true) { @@ -356,14 +322,45 @@ object Util { return null } + /** + * Check if a usable network for downloading media is available + * + * @return Boolean + */ @JvmStatic fun isNetworkConnected(): Boolean { - val manager = getConnectivityManager() - val networkInfo = manager.activeNetworkInfo - val connected = networkInfo != null && networkInfo.isConnected - val wifiConnected = connected && networkInfo!!.type == ConnectivityManager.TYPE_WIFI + val info = networkInfo() + val isUnmetered = info.unmetered val wifiRequired = Settings.isWifiRequiredForDownload - return connected && (!wifiRequired || wifiConnected) + return info.connected && (!wifiRequired || isUnmetered) + } + + /** + * Query connectivity status + * + * @return NetworkInfo object + */ + @Suppress("DEPRECATION") + fun networkInfo(): NetworkInfo { + val manager = getConnectivityManager() + val info = NetworkInfo() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network: Network? = manager.activeNetwork + val capabilities = manager.getNetworkCapabilities(network) + + if (capabilities != null) { + info.unmetered = capabilities.hasCapability(NET_CAPABILITY_NOT_METERED) + info.connected = capabilities.hasCapability(NET_CAPABILITY_INTERNET) + } + } else { + val networkInfo = manager.activeNetworkInfo + if (networkInfo != null) { + info.unmetered = networkInfo.type == ConnectivityManager.TYPE_WIFI + info.connected = networkInfo.isConnected + } + } + return info } @JvmStatic @@ -904,4 +901,23 @@ object Util { val context = appContext() return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager } + + /** + * Small data class to store information about the current network + **/ + data class NetworkInfo( + var connected: Boolean = false, + var unmetered: Boolean = false + ) + + /** + * Closes a Closeable while ignoring any errors. + **/ + fun Closeable?.safeClose() { + try { + this?.close() + } catch (_: Exception) { + // Ignored + } + } } diff --git a/ultrasonic/src/main/res/drawable/btn_bg.xml b/ultrasonic/src/main/res/drawable/btn_bg.xml deleted file mode 100644 index 79d40784..00000000 --- a/ultrasonic/src/main/res/drawable/btn_bg.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/ultrasonic/src/main/res/drawable/drop_shadow.xml b/ultrasonic/src/main/res/drawable/drop_shadow.xml index 19d83b51..3fcf3354 100644 --- a/ultrasonic/src/main/res/drawable/drop_shadow.xml +++ b/ultrasonic/src/main/res/drawable/drop_shadow.xml @@ -4,8 +4,8 @@ \ No newline at end of file diff --git a/ultrasonic/src/main/res/drawable/line.xml b/ultrasonic/src/main/res/drawable/line.xml deleted file mode 100644 index e3f2eaac..00000000 --- a/ultrasonic/src/main/res/drawable/line.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/ultrasonic/src/main/res/drawable/line_drawable.xml b/ultrasonic/src/main/res/drawable/line_drawable.xml deleted file mode 100644 index 4f79f470..00000000 --- a/ultrasonic/src/main/res/drawable/line_drawable.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/ultrasonic/src/main/res/layout/now_playing.xml b/ultrasonic/src/main/res/layout/now_playing.xml index 5cf76272..b19cca81 100644 --- a/ultrasonic/src/main/res/layout/now_playing.xml +++ b/ultrasonic/src/main/res/layout/now_playing.xml @@ -6,7 +6,7 @@ android:orientation="vertical" > @@ -21,7 +21,8 @@ android:layout_height="64.0dip" android:focusable="true" android:gravity="center" - android:layout_marginStart="6dp" /> + android:layout_marginStart="6dp" + android:importantForAccessibility="no"/> + android:src="?attr/media_pause" + android:contentDescription="@string/buttons.play"/> diff --git a/ultrasonic/src/main/res/layout/select_album_header.xml b/ultrasonic/src/main/res/layout/select_album_header.xml index 95e3096e..c33df633 100644 --- a/ultrasonic/src/main/res/layout/select_album_header.xml +++ b/ultrasonic/src/main/res/layout/select_album_header.xml @@ -7,7 +7,7 @@ a:id="@+id/select_album_art" a:layout_width="160dip" a:layout_height="160dip" - a:layout_alignParentLeft="true" + a:layout_alignParentStart="true" a:layout_alignParentTop="true" a:layout_marginEnd="10dip" a:contentDescription="@null" diff --git a/ultrasonic/src/main/res/layout/song_details.xml b/ultrasonic/src/main/res/layout/song_details.xml index 7cab9d5f..4de05f9d 100644 --- a/ultrasonic/src/main/res/layout/song_details.xml +++ b/ultrasonic/src/main/res/layout/song_details.xml @@ -26,10 +26,10 @@ a:layout_height="wrap_content" a:layout_gravity="left|center_vertical" a:layout_weight="1" - a:drawablePadding="6dip" + a:drawablePadding="4dip" a:ellipsize="end" - a:paddingStart="4dip" - a:paddingEnd="2dip" + a:paddingStart="6dip" + a:paddingEnd="4dip" a:singleLine="true" a:textAppearance="?android:attr/textAppearanceMedium"/> @@ -55,7 +55,8 @@ a:layout_gravity="left|center_vertical" a:layout_weight="1" a:ellipsize="middle" - a:paddingStart="4dip" + a:paddingStart="1dip" + a:paddingEnd="4dip" a:singleLine="true" a:textAppearance="?android:attr/textAppearanceSmall"/> diff --git a/ultrasonic/src/main/res/menu/main.xml b/ultrasonic/src/main/res/menu/main.xml deleted file mode 100644 index 78713454..00000000 --- a/ultrasonic/src/main/res/menu/main.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/ultrasonic/src/main/res/values/colors.xml b/ultrasonic/src/main/res/values/colors.xml index 9d5bf7c3..189a0d42 100644 --- a/ultrasonic/src/main/res/values/colors.xml +++ b/ultrasonic/src/main/res/values/colors.xml @@ -6,8 +6,6 @@ #0099cc #6200EE #BB86FC - #8033b5e5 - #00000000 #80000000 #000000 #333333 diff --git a/ultrasonic/src/main/res/values/strings.xml b/ultrasonic/src/main/res/values/strings.xml index 5ddee5ce..be3583e1 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -339,8 +339,8 @@ 4 seconds 4.5 seconds 5 seconds - Only stream media if connected to Wi-Fi - Wi-Fi Streaming Only + Only download media on unmetered connections + Download on Wi-Fi only %1$s%2$s %d kbps 0 B diff --git a/ultrasonic/src/main/res/values/themes.xml b/ultrasonic/src/main/res/values/themes.xml index 286b548c..4e23d827 100644 --- a/ultrasonic/src/main/res/values/themes.xml +++ b/ultrasonic/src/main/res/values/themes.xml @@ -64,7 +64,7 @@ @drawable/list_selector_holo_dark_selected -