965 lines
32 KiB
Kotlin
965 lines
32 KiB
Kotlin
/*
|
|
* Util.kt
|
|
* Copyright (C) 2009-2021 Ultrasonic developers
|
|
*
|
|
* Distributed under terms of the GNU GPLv3 license.
|
|
*/
|
|
|
|
package org.moire.ultrasonic.util
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.app.Activity
|
|
import android.app.AlertDialog
|
|
import android.app.PendingIntent
|
|
import android.content.ContentResolver
|
|
import android.content.Context
|
|
import android.content.DialogInterface
|
|
import android.content.Intent
|
|
import android.content.pm.PackageManager
|
|
import android.graphics.Bitmap
|
|
import android.graphics.BitmapFactory
|
|
import android.graphics.Canvas
|
|
import android.graphics.drawable.BitmapDrawable
|
|
import android.graphics.drawable.Drawable
|
|
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
|
|
import android.support.v4.media.MediaDescriptionCompat
|
|
import android.text.TextUtils
|
|
import android.util.TypedValue
|
|
import android.view.Gravity
|
|
import android.view.KeyEvent
|
|
import android.view.inputmethod.InputMethodManager
|
|
import android.widget.Toast
|
|
import androidx.annotation.AnyRes
|
|
import androidx.media.utils.MediaConstants
|
|
import java.io.Closeable
|
|
import java.io.File
|
|
import java.io.FileInputStream
|
|
import java.io.FileOutputStream
|
|
import java.io.IOException
|
|
import java.io.UnsupportedEncodingException
|
|
import java.security.MessageDigest
|
|
import java.text.DecimalFormat
|
|
import java.util.Locale
|
|
import java.util.concurrent.TimeUnit
|
|
import kotlin.math.max
|
|
import kotlin.math.min
|
|
import kotlin.math.roundToInt
|
|
import org.moire.ultrasonic.R
|
|
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
|
|
import org.moire.ultrasonic.domain.Bookmark
|
|
import org.moire.ultrasonic.domain.MusicDirectory
|
|
import org.moire.ultrasonic.domain.PlayerState
|
|
import org.moire.ultrasonic.domain.SearchResult
|
|
import org.moire.ultrasonic.service.DownloadFile
|
|
import timber.log.Timber
|
|
|
|
private const val LINE_LENGTH = 60
|
|
private const val DEGRADE_PRECISION_AFTER = 10
|
|
private const val MINUTES_IN_HOUR = 60
|
|
private const val KBYTE = 1024
|
|
|
|
/**
|
|
* Contains various utility functions
|
|
*/
|
|
@Suppress("TooManyFunctions", "LargeClass")
|
|
object Util {
|
|
|
|
private val GIGA_BYTE_FORMAT = DecimalFormat("0.00 GB")
|
|
private val MEGA_BYTE_FORMAT = DecimalFormat("0.00 MB")
|
|
private val KILO_BYTE_FORMAT = DecimalFormat("0 KB")
|
|
private var GIGA_BYTE_LOCALIZED_FORMAT: DecimalFormat? = null
|
|
private var MEGA_BYTE_LOCALIZED_FORMAT: DecimalFormat? = null
|
|
private var KILO_BYTE_LOCALIZED_FORMAT: DecimalFormat? = null
|
|
private var BYTE_LOCALIZED_FORMAT: DecimalFormat? = null
|
|
private const val EVENT_META_CHANGED = "org.moire.ultrasonic.EVENT_META_CHANGED"
|
|
private const val EVENT_PLAYSTATE_CHANGED = "org.moire.ultrasonic.EVENT_PLAYSTATE_CHANGED"
|
|
private const val CM_AVRCP_PLAYSTATE_CHANGED = "com.android.music.playstatechanged"
|
|
private const val CM_AVRCP_PLAYBACK_COMPLETE = "com.android.music.playbackcomplete"
|
|
private const val CM_AVRCP_METADATA_CHANGED = "com.android.music.metachanged"
|
|
|
|
// Used by hexEncode()
|
|
private val HEX_DIGITS =
|
|
charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
|
|
private var toast: Toast? = null
|
|
|
|
// Retrieves an instance of the application Context
|
|
fun appContext(): Context {
|
|
return applicationContext()
|
|
}
|
|
|
|
@JvmStatic
|
|
fun applyTheme(context: Context?) {
|
|
when (Settings.theme.lowercase()) {
|
|
Constants.PREFERENCES_KEY_THEME_DARK,
|
|
"fullscreen" -> {
|
|
context!!.setTheme(R.style.UltrasonicTheme)
|
|
}
|
|
Constants.PREFERENCES_KEY_THEME_BLACK -> {
|
|
context!!.setTheme(R.style.UltrasonicTheme_Black)
|
|
}
|
|
Constants.PREFERENCES_KEY_THEME_LIGHT,
|
|
"fullscreenlight" -> {
|
|
context!!.setTheme(R.style.UltrasonicTheme_Light)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Throws(IOException::class)
|
|
fun atomicCopy(from: File, to: File) {
|
|
val tmp = File(String.format(Locale.ROOT, "%s.tmp", to.path))
|
|
val input = FileInputStream(from)
|
|
val out = FileOutputStream(tmp)
|
|
try {
|
|
input.channel.transferTo(0, from.length(), out.channel)
|
|
out.close()
|
|
if (!tmp.renameTo(to)) {
|
|
throw IOException(
|
|
String.format(Locale.ROOT, "Failed to rename %s to %s", tmp, to)
|
|
)
|
|
}
|
|
Timber.i("Copied %s to %s", from, to)
|
|
} catch (x: IOException) {
|
|
close(out)
|
|
delete(to)
|
|
throw x
|
|
} finally {
|
|
close(input)
|
|
close(out)
|
|
delete(tmp)
|
|
}
|
|
}
|
|
|
|
@JvmStatic
|
|
@Throws(IOException::class)
|
|
fun renameFile(from: File, to: File) {
|
|
if (from.renameTo(to)) {
|
|
Timber.i("Renamed %s to %s", from, to)
|
|
} else {
|
|
atomicCopy(from, to)
|
|
}
|
|
}
|
|
|
|
@JvmStatic
|
|
fun close(closeable: Closeable?) {
|
|
try {
|
|
closeable?.close()
|
|
} catch (_: Throwable) {
|
|
// Ignored
|
|
}
|
|
}
|
|
|
|
@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
|
|
@JvmOverloads
|
|
fun toast(context: Context?, messageId: Int, shortDuration: Boolean = true) {
|
|
toast(context, context!!.getString(messageId), shortDuration)
|
|
}
|
|
|
|
@JvmStatic
|
|
fun toast(context: Context?, message: CharSequence?) {
|
|
toast(context, message, true)
|
|
}
|
|
|
|
@JvmStatic
|
|
@SuppressLint("ShowToast") // Invalid warning
|
|
fun toast(context: Context?, message: CharSequence?, shortDuration: Boolean) {
|
|
if (toast == null) {
|
|
toast = Toast.makeText(
|
|
context,
|
|
message,
|
|
if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG
|
|
)
|
|
toast!!.setGravity(Gravity.CENTER, 0, 0)
|
|
} else {
|
|
toast!!.setText(message)
|
|
toast!!.duration =
|
|
if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG
|
|
}
|
|
toast!!.show()
|
|
}
|
|
|
|
/**
|
|
* Formats an Int to a percentage string
|
|
* For instance:
|
|
*
|
|
* * `format(99)` returns *"99 %"*.
|
|
*
|
|
*
|
|
* @param percent The percent as a range from 0 - 100
|
|
* @return The formatted string.
|
|
*/
|
|
@Synchronized
|
|
fun formatPercentage(percent: Int): String {
|
|
return min(max(percent, 0), 100).toString() + " %"
|
|
}
|
|
|
|
/**
|
|
* Converts a byte-count to a formatted string suitable for display to the user.
|
|
* For instance:
|
|
*
|
|
* * `format(918)` returns *"918 B"*.
|
|
* * `format(98765)` returns *"96 KB"*.
|
|
* * `format(1238476)` returns *"1.2 MB"*.
|
|
*
|
|
* This method assumes that 1 KB is 1024 bytes.
|
|
* To get a localized string, please use formatLocalizedBytes instead.
|
|
*
|
|
* @param byteCount The number of bytes.
|
|
* @return The formatted string.
|
|
*/
|
|
@JvmStatic
|
|
@Synchronized
|
|
fun formatBytes(byteCount: Long): String {
|
|
|
|
// More than 1 GB?
|
|
if (byteCount >= KBYTE * KBYTE * KBYTE) {
|
|
return GIGA_BYTE_FORMAT.format(byteCount.toDouble() / (KBYTE * KBYTE * KBYTE))
|
|
}
|
|
|
|
// More than 1 MB?
|
|
if (byteCount >= KBYTE * KBYTE) {
|
|
return MEGA_BYTE_FORMAT.format(byteCount.toDouble() / (KBYTE * KBYTE))
|
|
}
|
|
|
|
// More than 1 KB?
|
|
return if (byteCount >= KBYTE) {
|
|
KILO_BYTE_FORMAT.format(byteCount.toDouble() / KBYTE)
|
|
} else "$byteCount B"
|
|
}
|
|
|
|
/**
|
|
* Converts a byte-count to a formatted string suitable for display to the user.
|
|
* For instance:
|
|
*
|
|
* * `format(918)` returns *"918 B"*.
|
|
* * `format(98765)` returns *"96 KB"*.
|
|
* * `format(1238476)` returns *"1.2 MB"*.
|
|
*
|
|
* This method assumes that 1 KB is 1024 bytes.
|
|
* This version of the method returns a localized string.
|
|
*
|
|
* @param byteCount The number of bytes.
|
|
* @return The formatted string.
|
|
*/
|
|
@Synchronized
|
|
@Suppress("ReturnCount")
|
|
fun formatLocalizedBytes(byteCount: Long, context: Context): String {
|
|
|
|
// More than 1 GB?
|
|
if (byteCount >= KBYTE * KBYTE * KBYTE) {
|
|
if (GIGA_BYTE_LOCALIZED_FORMAT == null) {
|
|
GIGA_BYTE_LOCALIZED_FORMAT =
|
|
DecimalFormat(context.resources.getString(R.string.util_bytes_format_gigabyte))
|
|
}
|
|
return GIGA_BYTE_LOCALIZED_FORMAT!!
|
|
.format(byteCount.toDouble() / (KBYTE * KBYTE * KBYTE))
|
|
}
|
|
|
|
// More than 1 MB?
|
|
if (byteCount >= KBYTE * KBYTE) {
|
|
if (MEGA_BYTE_LOCALIZED_FORMAT == null) {
|
|
MEGA_BYTE_LOCALIZED_FORMAT =
|
|
DecimalFormat(context.resources.getString(R.string.util_bytes_format_megabyte))
|
|
}
|
|
return MEGA_BYTE_LOCALIZED_FORMAT!!
|
|
.format(byteCount.toDouble() / (KBYTE * KBYTE))
|
|
}
|
|
|
|
// More than 1 KB?
|
|
if (byteCount >= KBYTE) {
|
|
if (KILO_BYTE_LOCALIZED_FORMAT == null) {
|
|
KILO_BYTE_LOCALIZED_FORMAT =
|
|
DecimalFormat(context.resources.getString(R.string.util_bytes_format_kilobyte))
|
|
}
|
|
return KILO_BYTE_LOCALIZED_FORMAT!!.format(byteCount.toDouble() / KBYTE)
|
|
}
|
|
if (BYTE_LOCALIZED_FORMAT == null) {
|
|
BYTE_LOCALIZED_FORMAT =
|
|
DecimalFormat(context.resources.getString(R.string.util_bytes_format_byte))
|
|
}
|
|
return BYTE_LOCALIZED_FORMAT!!.format(byteCount.toDouble())
|
|
}
|
|
|
|
@Suppress("SuspiciousEqualsCombination")
|
|
fun equals(object1: Any?, object2: Any?): Boolean {
|
|
return object1 === object2 || !(object1 == null || object2 == null) && object1 == object2
|
|
}
|
|
|
|
/**
|
|
* Encodes the given string by using the hexadecimal representation of its UTF-8 bytes.
|
|
*
|
|
* @param s The string to encode.
|
|
* @return The encoded string.
|
|
*/
|
|
@Suppress("TooGenericExceptionThrown", "TooGenericExceptionCaught")
|
|
fun utf8HexEncode(s: String?): String? {
|
|
if (s == null) {
|
|
return null
|
|
}
|
|
val utf8: ByteArray = try {
|
|
s.toByteArray(charset(Constants.UTF_8))
|
|
} catch (x: UnsupportedEncodingException) {
|
|
throw RuntimeException(x)
|
|
}
|
|
return hexEncode(utf8)
|
|
}
|
|
|
|
/**
|
|
* Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order.
|
|
* The returned array will be double the length of the passed array, as it takes two characters to represent any
|
|
* given byte.
|
|
*
|
|
* @param data Bytes to convert to hexadecimal characters.
|
|
* @return A string containing hexadecimal characters.
|
|
*/
|
|
@Suppress("MagicNumber")
|
|
fun hexEncode(data: ByteArray): String {
|
|
val length = data.size
|
|
val out = CharArray(length shl 1)
|
|
var j = 0
|
|
|
|
// two characters form the hex value.
|
|
for (aData in data) {
|
|
out[j++] = HEX_DIGITS[0xF0 and aData.toInt() ushr 4]
|
|
out[j++] = HEX_DIGITS[0x0F and aData.toInt()]
|
|
}
|
|
return String(out)
|
|
}
|
|
|
|
/**
|
|
* Calculates the MD5 digest and returns the value as a 32 character hex string.
|
|
*
|
|
* @param s Data to digest.
|
|
* @return MD5 digest as a hex string.
|
|
*/
|
|
@JvmStatic
|
|
@Suppress("TooGenericExceptionThrown", "TooGenericExceptionCaught")
|
|
fun md5Hex(s: String?): String? {
|
|
return if (s == null) {
|
|
null
|
|
} else try {
|
|
val md5 = MessageDigest.getInstance("MD5")
|
|
hexEncode(md5.digest(s.toByteArray(charset(Constants.UTF_8))))
|
|
} catch (x: Exception) {
|
|
throw RuntimeException(x.message, x)
|
|
}
|
|
}
|
|
|
|
@JvmStatic
|
|
fun getGrandparent(path: String?): String? {
|
|
// Find the top level folder, assume it is the album artist
|
|
if (path != null) {
|
|
val slashIndex = path.indexOf('/')
|
|
if (slashIndex > 0) {
|
|
return path.substring(0, slashIndex)
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Check if a usable network for downloading media is available
|
|
*
|
|
* @return Boolean
|
|
*/
|
|
@JvmStatic
|
|
fun isNetworkConnected(): Boolean {
|
|
val info = networkInfo()
|
|
val isUnmetered = info.unmetered
|
|
val wifiRequired = Settings.isWifiRequiredForDownload
|
|
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
|
|
fun isExternalStoragePresent(): Boolean =
|
|
Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()
|
|
|
|
// The AlertDialog requires an Activity context, app context is not enough
|
|
// See https://stackoverflow.com/questions/5436822/
|
|
fun createDialog(
|
|
context: Context?,
|
|
icon: Int = android.R.drawable.ic_dialog_info,
|
|
title: String,
|
|
message: String?
|
|
): AlertDialog.Builder {
|
|
return AlertDialog.Builder(context)
|
|
.setIcon(icon)
|
|
.setTitle(title)
|
|
.setMessage(message)
|
|
.setPositiveButton(R.string.common_ok) {
|
|
dialog: DialogInterface,
|
|
_: Int ->
|
|
dialog.dismiss()
|
|
}
|
|
}
|
|
|
|
fun showDialog(
|
|
context: Context,
|
|
icon: Int = android.R.drawable.ic_dialog_info,
|
|
titleId: Int,
|
|
message: String?
|
|
) {
|
|
createDialog(context, icon, context.getString(titleId, ""), message).show()
|
|
}
|
|
|
|
@JvmStatic
|
|
fun sleepQuietly(millis: Long) {
|
|
try {
|
|
Thread.sleep(millis)
|
|
} catch (x: InterruptedException) {
|
|
Timber.w(x, "Interrupted from sleep.")
|
|
}
|
|
}
|
|
|
|
@JvmStatic
|
|
fun getDrawableFromAttribute(context: Context?, attr: Int): Drawable {
|
|
val attrs = intArrayOf(attr)
|
|
val ta = context!!.obtainStyledAttributes(attrs)
|
|
val drawableFromTheme: Drawable? = ta.getDrawable(0)
|
|
ta.recycle()
|
|
return drawableFromTheme!!
|
|
}
|
|
|
|
fun createDrawableFromBitmap(context: Context, bitmap: Bitmap?): Drawable {
|
|
return BitmapDrawable(context.resources, bitmap)
|
|
}
|
|
|
|
fun createBitmapFromDrawable(drawable: Drawable): Bitmap {
|
|
if (drawable is BitmapDrawable) {
|
|
return drawable.bitmap
|
|
}
|
|
val bitmap = Bitmap.createBitmap(
|
|
drawable.intrinsicWidth,
|
|
drawable.intrinsicHeight,
|
|
Bitmap.Config.ARGB_8888
|
|
)
|
|
val canvas = Canvas(bitmap)
|
|
drawable.setBounds(0, 0, canvas.width, canvas.height)
|
|
drawable.draw(canvas)
|
|
return bitmap
|
|
}
|
|
|
|
fun createWifiLock(tag: String?): WifiLock {
|
|
val wm =
|
|
appContext().applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
|
return wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, tag)
|
|
}
|
|
|
|
fun getScaledHeight(height: Double, width: Double, newWidth: Int): Int {
|
|
// Try to keep correct aspect ratio of the original image, do not force a square
|
|
val aspectRatio = height / width
|
|
|
|
// Assume the size given refers to the width of the image, so calculate the new height using
|
|
// the previously determined aspect ratio
|
|
return (newWidth * aspectRatio).roundToInt()
|
|
}
|
|
|
|
private fun getScaledHeight(bitmap: Bitmap, width: Int): Int {
|
|
return getScaledHeight(bitmap.height.toDouble(), bitmap.width.toDouble(), width)
|
|
}
|
|
|
|
fun scaleBitmap(bitmap: Bitmap?, size: Int): Bitmap? {
|
|
return if (bitmap == null) null else Bitmap.createScaledBitmap(
|
|
bitmap,
|
|
size,
|
|
getScaledHeight(bitmap, size),
|
|
true
|
|
)
|
|
}
|
|
|
|
fun getSongsFromSearchResult(searchResult: SearchResult): MusicDirectory {
|
|
val musicDirectory = MusicDirectory()
|
|
for (entry in searchResult.songs) {
|
|
musicDirectory.addChild(entry)
|
|
}
|
|
return musicDirectory
|
|
}
|
|
|
|
@JvmStatic
|
|
fun getSongsFromBookmarks(bookmarks: Iterable<Bookmark?>): MusicDirectory {
|
|
val musicDirectory = MusicDirectory()
|
|
var song: MusicDirectory.Entry
|
|
for (bookmark in bookmarks) {
|
|
if (bookmark == null) continue
|
|
song = bookmark.entry
|
|
song.bookmarkPosition = bookmark.position
|
|
musicDirectory.addChild(song)
|
|
}
|
|
return musicDirectory
|
|
}
|
|
|
|
/**
|
|
*
|
|
* Broadcasts the given song info as the new song being played.
|
|
*/
|
|
fun broadcastNewTrackInfo(context: Context, song: MusicDirectory.Entry?) {
|
|
val intent = Intent(EVENT_META_CHANGED)
|
|
if (song != null) {
|
|
intent.putExtra("title", song.title)
|
|
intent.putExtra("artist", song.artist)
|
|
intent.putExtra("album", song.album)
|
|
val albumArtFile = FileUtil.getAlbumArtFile(song)
|
|
intent.putExtra("coverart", albumArtFile.absolutePath)
|
|
} else {
|
|
intent.putExtra("title", "")
|
|
intent.putExtra("artist", "")
|
|
intent.putExtra("album", "")
|
|
intent.putExtra("coverart", "")
|
|
}
|
|
context.sendBroadcast(intent)
|
|
}
|
|
|
|
fun broadcastA2dpMetaDataChange(
|
|
context: Context,
|
|
playerPosition: Int,
|
|
currentPlaying: DownloadFile?,
|
|
listSize: Int,
|
|
id: Int
|
|
) {
|
|
if (!Settings.shouldSendBluetoothNotifications) return
|
|
|
|
var song: MusicDirectory.Entry? = null
|
|
val avrcpIntent = Intent(CM_AVRCP_METADATA_CHANGED)
|
|
if (currentPlaying != null) song = currentPlaying.song
|
|
|
|
fillIntent(avrcpIntent, song, playerPosition, id, listSize)
|
|
|
|
context.sendBroadcast(avrcpIntent)
|
|
}
|
|
|
|
@Suppress("LongParameterList")
|
|
fun broadcastA2dpPlayStatusChange(
|
|
context: Context,
|
|
state: PlayerState?,
|
|
newSong: MusicDirectory.Entry?,
|
|
listSize: Int,
|
|
id: Int,
|
|
playerPosition: Int
|
|
) {
|
|
if (!Settings.shouldSendBluetoothNotifications) return
|
|
|
|
if (newSong != null) {
|
|
|
|
val avrcpIntent = Intent(
|
|
if (state == PlayerState.COMPLETED) CM_AVRCP_PLAYBACK_COMPLETE
|
|
else CM_AVRCP_PLAYSTATE_CHANGED
|
|
)
|
|
|
|
fillIntent(avrcpIntent, newSong, playerPosition, id, listSize)
|
|
|
|
if (state != PlayerState.COMPLETED) {
|
|
when (state) {
|
|
PlayerState.STARTED -> avrcpIntent.putExtra("playing", true)
|
|
PlayerState.STOPPED,
|
|
PlayerState.PAUSED -> avrcpIntent.putExtra("playing", false)
|
|
else -> return // No need to broadcast.
|
|
}
|
|
}
|
|
|
|
context.sendBroadcast(avrcpIntent)
|
|
}
|
|
}
|
|
|
|
private fun fillIntent(
|
|
intent: Intent,
|
|
song: MusicDirectory.Entry?,
|
|
playerPosition: Int,
|
|
id: Int,
|
|
listSize: Int
|
|
) {
|
|
if (song == null) {
|
|
intent.putExtra("track", "")
|
|
intent.putExtra("track_name", "")
|
|
intent.putExtra("artist", "")
|
|
intent.putExtra("artist_name", "")
|
|
intent.putExtra("album", "")
|
|
intent.putExtra("album_name", "")
|
|
intent.putExtra("album_artist", "")
|
|
intent.putExtra("album_artist_name", "")
|
|
|
|
if (Settings.shouldSendBluetoothAlbumArt) {
|
|
intent.putExtra("coverart", null as Parcelable?)
|
|
intent.putExtra("cover", null as Parcelable?)
|
|
}
|
|
|
|
intent.putExtra("ListSize", 0.toLong())
|
|
intent.putExtra("id", 0.toLong())
|
|
intent.putExtra("duration", 0.toLong())
|
|
intent.putExtra("position", 0.toLong())
|
|
} else {
|
|
val title = song.title
|
|
val artist = song.artist
|
|
val album = song.album
|
|
val duration = song.duration
|
|
|
|
intent.putExtra("track", title)
|
|
intent.putExtra("track_name", title)
|
|
intent.putExtra("artist", artist)
|
|
intent.putExtra("artist_name", artist)
|
|
intent.putExtra("album", album)
|
|
intent.putExtra("album_name", album)
|
|
intent.putExtra("album_artist", artist)
|
|
intent.putExtra("album_artist_name", artist)
|
|
|
|
if (Settings.shouldSendBluetoothAlbumArt) {
|
|
val albumArtFile = FileUtil.getAlbumArtFile(song)
|
|
intent.putExtra("coverart", albumArtFile.absolutePath)
|
|
intent.putExtra("cover", albumArtFile.absolutePath)
|
|
}
|
|
|
|
intent.putExtra("position", playerPosition.toLong())
|
|
intent.putExtra("id", id.toLong())
|
|
intent.putExtra("ListSize", listSize.toLong())
|
|
|
|
if (duration != null) {
|
|
intent.putExtra("duration", duration.toLong())
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* Broadcasts the given player state as the one being set.
|
|
*/
|
|
fun broadcastPlaybackStatusChange(context: Context, state: PlayerState?) {
|
|
val intent = Intent(EVENT_PLAYSTATE_CHANGED)
|
|
when (state) {
|
|
PlayerState.STARTED -> intent.putExtra("state", "play")
|
|
PlayerState.STOPPED -> intent.putExtra("state", "stop")
|
|
PlayerState.PAUSED -> intent.putExtra("state", "pause")
|
|
PlayerState.COMPLETED -> intent.putExtra("state", "complete")
|
|
else -> return // No need to broadcast.
|
|
}
|
|
context.sendBroadcast(intent)
|
|
}
|
|
|
|
@JvmStatic
|
|
@Suppress("MagicNumber")
|
|
fun getNotificationImageSize(context: Context): Int {
|
|
val metrics = context.resources.displayMetrics
|
|
val imageSizeLarge =
|
|
min(metrics.widthPixels, metrics.heightPixels).toFloat().roundToInt()
|
|
return when {
|
|
imageSizeLarge <= 480 -> {
|
|
64
|
|
}
|
|
imageSizeLarge <= 768 -> 128
|
|
else -> 256
|
|
}
|
|
}
|
|
|
|
@Suppress("MagicNumber")
|
|
fun getAlbumImageSize(context: Context?): Int {
|
|
val metrics = context!!.resources.displayMetrics
|
|
val imageSizeLarge =
|
|
min(metrics.widthPixels, metrics.heightPixels).toFloat().roundToInt()
|
|
return when {
|
|
imageSizeLarge <= 480 -> {
|
|
128
|
|
}
|
|
imageSizeLarge <= 768 -> 256
|
|
else -> 512
|
|
}
|
|
}
|
|
|
|
fun getMinDisplayMetric(): Int {
|
|
val metrics = appContext().resources.displayMetrics
|
|
return min(metrics.widthPixels, metrics.heightPixels)
|
|
}
|
|
|
|
fun getMaxDisplayMetric(): Int {
|
|
val metrics = appContext().resources.displayMetrics
|
|
return max(metrics.widthPixels, metrics.heightPixels)
|
|
}
|
|
|
|
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
|
|
// Raw height and width of image
|
|
val height = options.outHeight
|
|
val width = options.outWidth
|
|
var inSampleSize = 1
|
|
if (height > reqHeight || width > reqWidth) {
|
|
|
|
// Calculate ratios of height and width to requested height and
|
|
// width
|
|
val heightRatio = (height.toFloat() / reqHeight.toFloat()).roundToInt()
|
|
val widthRatio = (width.toFloat() / reqWidth.toFloat()).roundToInt()
|
|
|
|
// Choose the smallest ratio as inSampleSize value, this will
|
|
// guarantee
|
|
// a final image with both dimensions larger than or equal to the
|
|
// requested height and width.
|
|
inSampleSize = min(heightRatio, widthRatio)
|
|
}
|
|
return inSampleSize
|
|
}
|
|
|
|
@JvmStatic
|
|
fun isNullOrWhiteSpace(string: String?): Boolean {
|
|
return string == null || string.isEmpty() || string.trim { it <= ' ' }.isEmpty()
|
|
}
|
|
|
|
@JvmOverloads
|
|
fun formatTotalDuration(totalDuration: Long, inMilliseconds: Boolean = false): String {
|
|
var millis = totalDuration
|
|
if (!inMilliseconds) {
|
|
millis = totalDuration * 1000
|
|
}
|
|
val hours = TimeUnit.MILLISECONDS.toHours(millis)
|
|
val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) - TimeUnit.HOURS.toMinutes(hours)
|
|
val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) -
|
|
TimeUnit.MINUTES.toSeconds(hours * MINUTES_IN_HOUR + minutes)
|
|
|
|
return when {
|
|
hours >= DEGRADE_PRECISION_AFTER -> {
|
|
String.format(
|
|
Locale.getDefault(),
|
|
"%02d:%02d:%02d",
|
|
hours,
|
|
minutes,
|
|
seconds
|
|
)
|
|
}
|
|
hours > 0 -> {
|
|
String.format(Locale.getDefault(), "%d:%02d:%02d", hours, minutes, seconds)
|
|
}
|
|
minutes >= DEGRADE_PRECISION_AFTER -> {
|
|
String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds)
|
|
}
|
|
minutes > 0 -> String.format(
|
|
Locale.getDefault(),
|
|
"%d:%02d",
|
|
minutes,
|
|
seconds
|
|
)
|
|
else -> String.format(Locale.getDefault(), "0:%02d", seconds)
|
|
}
|
|
}
|
|
|
|
@JvmStatic
|
|
fun getVersionName(context: Context): String? {
|
|
var versionName: String? = null
|
|
val pm = context.packageManager
|
|
if (pm != null) {
|
|
val packageName = context.packageName
|
|
try {
|
|
versionName = pm.getPackageInfo(packageName, 0).versionName
|
|
} catch (ignored: PackageManager.NameNotFoundException) {
|
|
}
|
|
}
|
|
return versionName
|
|
}
|
|
|
|
fun getVersionCode(context: Context): Int {
|
|
var versionCode = 0
|
|
val pm = context.packageManager
|
|
if (pm != null) {
|
|
val packageName = context.packageName
|
|
try {
|
|
versionCode = pm.getPackageInfo(packageName, 0).versionCode
|
|
} catch (ignored: PackageManager.NameNotFoundException) {
|
|
}
|
|
}
|
|
return versionCode
|
|
}
|
|
|
|
@JvmStatic
|
|
fun scanMedia(file: File?) {
|
|
val uri = Uri.fromFile(file)
|
|
val scanFileIntent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri)
|
|
appContext().sendBroadcast(scanFileIntent)
|
|
}
|
|
|
|
fun getResourceFromAttribute(context: Context, resId: Int): Int {
|
|
val typedValue = TypedValue()
|
|
val theme = context.theme
|
|
theme.resolveAttribute(resId, typedValue, true)
|
|
return typedValue.resourceId
|
|
}
|
|
|
|
fun isFirstRun(): Boolean {
|
|
if (Settings.firstRunExecuted) return false
|
|
|
|
Settings.firstRunExecuted = true
|
|
return true
|
|
}
|
|
|
|
fun hideKeyboard(activity: Activity?) {
|
|
val inputManager =
|
|
activity!!.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
|
val currentFocusedView = activity.currentFocus
|
|
if (currentFocusedView != null) {
|
|
inputManager.hideSoftInputFromWindow(
|
|
currentFocusedView.windowToken,
|
|
InputMethodManager.HIDE_NOT_ALWAYS
|
|
)
|
|
}
|
|
}
|
|
|
|
fun getUriToDrawable(context: Context, @AnyRes drawableId: Int): Uri {
|
|
return Uri.parse(
|
|
ContentResolver.SCHEME_ANDROID_RESOURCE +
|
|
"://" + context.resources.getResourcePackageName(drawableId) +
|
|
'/' + context.resources.getResourceTypeName(drawableId) +
|
|
'/' + context.resources.getResourceEntryName(drawableId)
|
|
)
|
|
}
|
|
|
|
@Suppress("ComplexMethod", "LongMethod")
|
|
fun getMediaDescriptionForEntry(
|
|
song: MusicDirectory.Entry,
|
|
mediaId: String? = null,
|
|
groupNameId: Int? = null
|
|
): MediaDescriptionCompat {
|
|
|
|
val descriptionBuilder = MediaDescriptionCompat.Builder()
|
|
val artist = StringBuilder(LINE_LENGTH)
|
|
var bitRate: String? = null
|
|
|
|
val duration = song.duration
|
|
if (duration != null) {
|
|
artist.append(
|
|
String.format(Locale.ROOT, "%s ", formatTotalDuration(duration.toLong()))
|
|
)
|
|
}
|
|
|
|
if (song.bitRate != null && song.bitRate!! > 0)
|
|
bitRate = String.format(
|
|
appContext().getString(R.string.song_details_kbps), song.bitRate
|
|
)
|
|
|
|
val fileFormat: String?
|
|
val suffix = song.suffix
|
|
val transcodedSuffix = song.transcodedSuffix
|
|
|
|
fileFormat = if (
|
|
TextUtils.isEmpty(transcodedSuffix) || transcodedSuffix == suffix || song.isVideo
|
|
) suffix else String.format(Locale.ROOT, "%s > %s", suffix, transcodedSuffix)
|
|
|
|
val artistName = song.artist
|
|
|
|
if (artistName != null) {
|
|
if (Settings.shouldDisplayBitrateWithArtist && (
|
|
!bitRate.isNullOrBlank() || !fileFormat.isNullOrBlank()
|
|
)
|
|
) {
|
|
artist.append(artistName).append(" (").append(
|
|
String.format(
|
|
appContext().getString(R.string.song_details_all),
|
|
if (bitRate == null) ""
|
|
else String.format(Locale.ROOT, "%s ", bitRate),
|
|
fileFormat
|
|
)
|
|
).append(')')
|
|
} else {
|
|
artist.append(artistName)
|
|
}
|
|
}
|
|
|
|
val trackNumber = song.track ?: 0
|
|
|
|
val title = StringBuilder(LINE_LENGTH)
|
|
if (Settings.shouldShowTrackNumber && trackNumber > 0)
|
|
title.append(String.format(Locale.ROOT, "%02d - ", trackNumber))
|
|
|
|
title.append(song.title)
|
|
|
|
if (song.isVideo && Settings.shouldDisplayBitrateWithArtist) {
|
|
title.append(" (").append(
|
|
String.format(
|
|
appContext().getString(R.string.song_details_all),
|
|
if (bitRate == null) ""
|
|
else String.format(Locale.ROOT, "%s ", bitRate),
|
|
fileFormat
|
|
)
|
|
).append(')')
|
|
}
|
|
|
|
if (groupNameId != null)
|
|
descriptionBuilder.setExtras(
|
|
Bundle().apply {
|
|
putString(
|
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
|
|
appContext().getString(groupNameId)
|
|
)
|
|
}
|
|
)
|
|
|
|
descriptionBuilder.setTitle(title)
|
|
descriptionBuilder.setSubtitle(artist)
|
|
descriptionBuilder.setMediaId(mediaId)
|
|
|
|
return descriptionBuilder.build()
|
|
}
|
|
|
|
fun getPendingIntentForMediaAction(
|
|
context: Context,
|
|
keycode: Int,
|
|
requestCode: Int
|
|
): PendingIntent {
|
|
val intent = Intent(Constants.CMD_PROCESS_KEYCODE)
|
|
val flags = PendingIntent.FLAG_UPDATE_CURRENT
|
|
intent.setPackage(context.packageName)
|
|
intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keycode))
|
|
return PendingIntent.getBroadcast(context, requestCode, intent, flags)
|
|
}
|
|
|
|
fun getConnectivityManager(): ConnectivityManager {
|
|
val context = appContext()
|
|
return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
}
|
|
|
|
data class NetworkInfo(
|
|
var connected: Boolean = false,
|
|
var unmetered: Boolean = false
|
|
)
|
|
}
|