Reinstate package name
Signed-off-by: Muntashir Al-Islam <muntashirakon@riseup.net>
This commit is contained in:
parent
9971c25649
commit
2845945763
478 changed files with 2648 additions and 2613 deletions
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
|
||||
/** @author Karim Abou Zeid (kabouzeid)
|
||||
*/
|
||||
class ArtistSignatureUtil private constructor(context: Context) {
|
||||
private val mPreferences: SharedPreferences =
|
||||
context.getSharedPreferences(ARTIST_SIGNATURE_PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
fun updateArtistSignature(artistName: String?) {
|
||||
mPreferences.edit { putLong(artistName, System.currentTimeMillis()) }
|
||||
}
|
||||
|
||||
private fun getArtistSignatureRaw(artistName: String?): Long {
|
||||
return mPreferences.getLong(artistName, 0)
|
||||
}
|
||||
|
||||
fun getArtistSignature(artistName: String?): ObjectKey {
|
||||
return ObjectKey(getArtistSignatureRaw(artistName).toString())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARTIST_SIGNATURE_PREFS = "artist_signatures"
|
||||
private var INSTANCE: ArtistSignatureUtil? = null
|
||||
fun getInstance(context: Context): ArtistSignatureUtil {
|
||||
if (INSTANCE == null) {
|
||||
INSTANCE = ArtistSignatureUtil(context.applicationContext)
|
||||
}
|
||||
return INSTANCE!!
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import code.name.monkey.retromusic.R
|
||||
import code.name.monkey.retromusic.glide.GlideApp
|
||||
import code.name.monkey.retromusic.model.Song
|
||||
import code.name.monkey.retromusic.util.MergedImageUtils.joinImages
|
||||
import code.name.monkey.retromusic.util.MusicUtil.getMediaStoreAlbumCoverUri
|
||||
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
|
||||
|
||||
object AutoGeneratedPlaylistBitmap {
|
||||
fun getBitmap(
|
||||
context: Context, songPlaylist: List<Song>?
|
||||
): Bitmap? {
|
||||
if (songPlaylist == null || songPlaylist.isEmpty()) return getDefaultBitmap(context)
|
||||
if (songPlaylist.size == 1) return getBitmapWithAlbumId(context, songPlaylist[0].albumId)
|
||||
val albumID: MutableList<Long> = ArrayList()
|
||||
for (song in songPlaylist) {
|
||||
if (!albumID.contains(song.albumId)) albumID.add(song.albumId)
|
||||
}
|
||||
val art: MutableList<Bitmap> = ArrayList()
|
||||
for (id in albumID) {
|
||||
val bitmap = getBitmapWithAlbumId(context, id)
|
||||
if (bitmap != null) art.add(bitmap)
|
||||
if (art.size == 9) break
|
||||
}
|
||||
return joinImages(art)
|
||||
}
|
||||
|
||||
private fun getBitmapWithAlbumId(context: Context, id: Long): Bitmap? {
|
||||
return try {
|
||||
GlideApp.with(context)
|
||||
.asBitmap()
|
||||
.transform(RoundedCorners(20))
|
||||
.load(getMediaStoreAlbumCoverUri(id))
|
||||
.submit(200, 200)
|
||||
.get()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDefaultBitmap(context: Context): Bitmap {
|
||||
return BitmapFactory.decodeResource(context.resources, R.drawable.default_album_art)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import code.name.monkey.retromusic.extensions.showToast
|
||||
import java.io.File
|
||||
|
||||
object BackupUtil {
|
||||
fun createShareFileIntent(file: File, context: Context): Intent? {
|
||||
return try {
|
||||
Intent().setAction(Intent.ACTION_SEND).putExtra(
|
||||
Intent.EXTRA_STREAM,
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
context.applicationContext.packageName,
|
||||
file
|
||||
)
|
||||
).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION).setType("*/*")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.printStackTrace()
|
||||
context.showToast(
|
||||
"Could not share this file."
|
||||
)
|
||||
Intent()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
package code.name.monkey.retromusic.util
|
||||
|
||||
import java.util.*
|
||||
|
||||
/** @author Eugene Cheung (arkon)
|
||||
*/
|
||||
class CalendarUtil {
|
||||
private val calendar = Calendar.getInstance()// Time elapsed so far today
|
||||
|
||||
/**
|
||||
* Returns the time elapsed so far today in milliseconds.
|
||||
*
|
||||
* @return Time elapsed today in milliseconds.
|
||||
*/
|
||||
val elapsedToday: Long
|
||||
get() =// Time elapsed so far today
|
||||
(calendar[Calendar.HOUR_OF_DAY] * 60 + calendar[Calendar.MINUTE]) * MS_PER_MINUTE + calendar[Calendar.SECOND] * 1000 + calendar[Calendar.MILLISECOND]// Today + days passed this week
|
||||
|
||||
/**
|
||||
* Returns the time elapsed so far this week in milliseconds.
|
||||
*
|
||||
* @return Time elapsed this week in milliseconds.
|
||||
*/
|
||||
val elapsedWeek: Long
|
||||
get() {
|
||||
// Today + days passed this week
|
||||
var elapsed = elapsedToday
|
||||
val passedWeekdays = calendar[Calendar.DAY_OF_WEEK] - 1 - calendar.firstDayOfWeek
|
||||
if (passedWeekdays > 0) {
|
||||
elapsed += passedWeekdays * MS_PER_DAY
|
||||
}
|
||||
return elapsed
|
||||
}// Today + rest of this month
|
||||
|
||||
/**
|
||||
* Returns the time elapsed so far this month in milliseconds.
|
||||
*
|
||||
* @return Time elapsed this month in milliseconds.
|
||||
*/
|
||||
val elapsedMonth: Long
|
||||
get() =// Today + rest of this month
|
||||
elapsedToday + (calendar[Calendar.DAY_OF_MONTH] - 1) * MS_PER_DAY
|
||||
|
||||
/**
|
||||
* Returns the time elapsed so far this month and the last numMonths months in milliseconds.
|
||||
*
|
||||
* @param numMonths Additional number of months prior to the current month to calculate.
|
||||
* @return Time elapsed this month and the last numMonths months in milliseconds.
|
||||
*/
|
||||
fun getElapsedMonths(numMonths: Int): Long {
|
||||
// Today + rest of this month
|
||||
var elapsed = elapsedMonth
|
||||
|
||||
// Previous numMonths months
|
||||
var month = calendar[Calendar.MONTH]
|
||||
var year = calendar[Calendar.YEAR]
|
||||
for (i in 0 until numMonths) {
|
||||
month--
|
||||
if (month < Calendar.JANUARY) {
|
||||
month = Calendar.DECEMBER
|
||||
year--
|
||||
}
|
||||
elapsed += getDaysInMonth(month) * MS_PER_DAY
|
||||
}
|
||||
return elapsed
|
||||
}// Today + rest of this month + previous months until January
|
||||
|
||||
/**
|
||||
* Returns the time elapsed so far this year in milliseconds.
|
||||
*
|
||||
* @return Time elapsed this year in milliseconds.
|
||||
*/
|
||||
val elapsedYear: Long
|
||||
get() {
|
||||
// Today + rest of this month + previous months until January
|
||||
var elapsed = elapsedMonth
|
||||
var month = calendar[Calendar.MONTH] - 1
|
||||
while (month > Calendar.JANUARY) {
|
||||
elapsed += getDaysInMonth(month) * MS_PER_DAY
|
||||
month--
|
||||
}
|
||||
return elapsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of days for the given month in the given year.
|
||||
*
|
||||
* @param month The month (1 - 12).
|
||||
* @return The days in that month/year.
|
||||
*/
|
||||
private fun getDaysInMonth(month: Int): Int {
|
||||
val monthCal: Calendar = GregorianCalendar(calendar[Calendar.YEAR], month, 1)
|
||||
return monthCal.getActualMaximum(Calendar.DAY_OF_MONTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the time elapsed so far last N days in milliseconds.
|
||||
*
|
||||
* @return Time elapsed since N days in milliseconds.
|
||||
*/
|
||||
fun getElapsedDays(numDays: Int): Long {
|
||||
var elapsed = elapsedToday
|
||||
elapsed += numDays * MS_PER_DAY
|
||||
return elapsed
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MS_PER_MINUTE = (60 * 1000).toLong()
|
||||
private const val MS_PER_DAY = 24 * 60 * MS_PER_MINUTE
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
package code.name.monkey.retromusic.util;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.palette.graphics.Palette;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
|
||||
public class ColorUtil {
|
||||
|
||||
@Nullable
|
||||
public static Palette generatePalette(Bitmap bitmap) {
|
||||
if (bitmap == null) return null;
|
||||
return Palette.from(bitmap).generate();
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
public static int getColor(@Nullable Palette palette, int fallback) {
|
||||
if (palette != null) {
|
||||
if (palette.getVibrantSwatch() != null) {
|
||||
return palette.getVibrantSwatch().getRgb();
|
||||
} else if (palette.getMutedSwatch() != null) {
|
||||
return palette.getMutedSwatch().getRgb();
|
||||
} else if (palette.getDarkVibrantSwatch() != null) {
|
||||
return palette.getDarkVibrantSwatch().getRgb();
|
||||
} else if (palette.getDarkMutedSwatch() != null) {
|
||||
return palette.getDarkMutedSwatch().getRgb();
|
||||
} else if (palette.getLightVibrantSwatch() != null) {
|
||||
return palette.getLightVibrantSwatch().getRgb();
|
||||
} else if (palette.getLightMutedSwatch() != null) {
|
||||
return palette.getLightMutedSwatch().getRgb();
|
||||
} else if (!palette.getSwatches().isEmpty()) {
|
||||
return Collections.max(palette.getSwatches(), SwatchComparator.getInstance()).getRgb();
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static class SwatchComparator implements Comparator<Palette.Swatch> {
|
||||
private static SwatchComparator sInstance;
|
||||
|
||||
static SwatchComparator getInstance() {
|
||||
if (sInstance == null) {
|
||||
sInstance = new SwatchComparator();
|
||||
}
|
||||
return sInstance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compare(Palette.Swatch lhs, Palette.Swatch rhs) {
|
||||
return lhs.getPopulation() - rhs.getPopulation();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
|
||||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.edit
|
||||
import code.name.monkey.retromusic.App
|
||||
import code.name.monkey.retromusic.R
|
||||
import code.name.monkey.retromusic.extensions.showToast
|
||||
import code.name.monkey.retromusic.glide.GlideApp
|
||||
import code.name.monkey.retromusic.model.Artist
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
|
||||
class CustomArtistImageUtil private constructor(context: Context) {
|
||||
|
||||
private val mPreferences: SharedPreferences = context.applicationContext.getSharedPreferences(
|
||||
CUSTOM_ARTIST_IMAGE_PREFS,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
suspend fun setCustomArtistImage(artist: Artist, uri: Uri) {
|
||||
val context = App.getContext()
|
||||
withContext(IO) {
|
||||
runCatching {
|
||||
GlideApp.with(context)
|
||||
.asBitmap()
|
||||
.load(uri)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
.submit()
|
||||
.get()
|
||||
}
|
||||
.onSuccess {
|
||||
saveImage(context, artist, it)
|
||||
}
|
||||
.onFailure {
|
||||
context.showToast(R.string.error_load_failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveImage(context: Context, artist: Artist, bitmap: Bitmap) {
|
||||
val dir = File(context.filesDir, FOLDER_NAME)
|
||||
if (!dir.exists()) {
|
||||
if (!dir.mkdirs()) { // create the folder
|
||||
return
|
||||
}
|
||||
}
|
||||
val file = File(dir, getFileName(artist))
|
||||
|
||||
var successful = false
|
||||
try {
|
||||
file.outputStream().buffered().use { bos ->
|
||||
successful = ImageUtil.resizeBitmap(bitmap, 2048)
|
||||
.compress(Bitmap.CompressFormat.JPEG, 100, bos)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
context.showToast(e.toString(), Toast.LENGTH_LONG)
|
||||
}
|
||||
|
||||
if (successful) {
|
||||
mPreferences.edit { putBoolean(getFileName(artist), true) }
|
||||
ArtistSignatureUtil.getInstance(context)
|
||||
.updateArtistSignature(artist.name)
|
||||
context.contentResolver.notifyChange(
|
||||
MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI,
|
||||
null
|
||||
) // trigger media store changed to force artist image reload
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetCustomArtistImage(artist: Artist) {
|
||||
withContext(IO) {
|
||||
mPreferences.edit { putBoolean(getFileName(artist), false) }
|
||||
ArtistSignatureUtil.getInstance(App.getContext()).updateArtistSignature(artist.name)
|
||||
App.getContext().contentResolver.notifyChange(
|
||||
MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI,
|
||||
null
|
||||
) // trigger media store changed to force artist image reload
|
||||
|
||||
val file = getFile(artist)
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shared prefs saves us many IO operations
|
||||
fun hasCustomArtistImage(artist: Artist): Boolean {
|
||||
return mPreferences.getBoolean(getFileName(artist), false)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CUSTOM_ARTIST_IMAGE_PREFS = "custom_artist_image"
|
||||
private const val FOLDER_NAME = "/custom_artist_images/"
|
||||
|
||||
private var sInstance: CustomArtistImageUtil? = null
|
||||
|
||||
fun getInstance(context: Context): CustomArtistImageUtil {
|
||||
if (sInstance == null) {
|
||||
sInstance = CustomArtistImageUtil(context.applicationContext)
|
||||
}
|
||||
return sInstance!!
|
||||
}
|
||||
|
||||
fun getFileName(artist: Artist): String {
|
||||
var artistName = artist.name
|
||||
// replace everything that is not a letter or a number with _
|
||||
artistName = artistName.replace("[^a-zA-Z0-9]".toRegex(), "_")
|
||||
return String.format(Locale.US, "#%d#%s.jpeg", artist.id, artistName)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getFile(artist: Artist): File {
|
||||
val dir = File(App.getContext().filesDir, FOLDER_NAME)
|
||||
return File(dir, getFileName(artist))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.TypedValue
|
||||
|
||||
/**
|
||||
* Created by hefuyi on 16/7/30.
|
||||
*/
|
||||
object DensityUtil {
|
||||
fun getScreenHeight(context: Context): Int {
|
||||
val displayMetrics = context.resources.displayMetrics
|
||||
return displayMetrics.heightPixels
|
||||
}
|
||||
|
||||
fun getScreenWidth(context: Context): Int {
|
||||
val displayMetrics = context.resources.displayMetrics
|
||||
return displayMetrics.widthPixels
|
||||
}
|
||||
|
||||
private fun toDP(context: Context, value: Int): Int {
|
||||
return TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
value.toFloat(), context.resources.displayMetrics
|
||||
).toInt()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun dip2px(context: Context, dpVale: Float): Int {
|
||||
val scale = context.resources.displayMetrics.density
|
||||
return (dpVale * scale + 0.5f).toInt()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.os.Environment
|
||||
|
||||
object FilePathUtil {
|
||||
fun blacklistFilePaths(): List<String> {
|
||||
return listOf(
|
||||
getExternalStoragePublicDirectory(Environment.DIRECTORY_ALARMS),
|
||||
getExternalStoragePublicDirectory(Environment.DIRECTORY_RINGTONES),
|
||||
getExternalStoragePublicDirectory(Environment.DIRECTORY_NOTIFICATIONS)
|
||||
).map {
|
||||
FileUtil.safeGetCanonicalPath(it)
|
||||
}
|
||||
}
|
||||
}
|
334
app/src/main/java/code/name/monkey/retromusic/util/FileUtil.java
Normal file
334
app/src/main/java/code/name/monkey/retromusic/util/FileUtil.java
Normal file
|
@ -0,0 +1,334 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
|
||||
package code.name.monkey.retromusic.util;
|
||||
|
||||
import static code.name.monkey.retromusic.util.FileUtilsKt.getExternalStorageDirectory;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.os.Environment;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileFilter;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
import code.name.monkey.retromusic.Constants;
|
||||
import code.name.monkey.retromusic.adapter.Storage;
|
||||
import code.name.monkey.retromusic.model.Song;
|
||||
import code.name.monkey.retromusic.repository.RealSongRepository;
|
||||
import code.name.monkey.retromusic.repository.SortedCursor;
|
||||
|
||||
public final class FileUtil {
|
||||
|
||||
private FileUtil() {}
|
||||
|
||||
public static byte[] readBytes(InputStream stream) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[4096];
|
||||
int count;
|
||||
while ((count = stream.read(buffer)) != -1) {
|
||||
baos.write(buffer, 0, count);
|
||||
}
|
||||
stream.close();
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static List<Song> matchFilesWithMediaStore(
|
||||
@NonNull Context context, @Nullable List<File> files) {
|
||||
return new RealSongRepository(context).songs(makeSongCursor(context, files));
|
||||
}
|
||||
|
||||
public static String safeGetCanonicalPath(File file) {
|
||||
try {
|
||||
return file.getCanonicalPath();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
return file.getAbsolutePath();
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static SortedCursor makeSongCursor(
|
||||
@NonNull final Context context, @Nullable final List<File> files) {
|
||||
String selection = null;
|
||||
String[] paths = null;
|
||||
|
||||
if (files != null) {
|
||||
paths = toPathArray(files);
|
||||
|
||||
if (files.size() > 0
|
||||
&& files.size() < 999) { // 999 is the max amount Androids SQL implementation can handle.
|
||||
selection =
|
||||
Constants.DATA + " IN (" + makePlaceholders(files.size()) + ")";
|
||||
}
|
||||
}
|
||||
|
||||
Cursor songCursor =
|
||||
new RealSongRepository(context).makeSongCursor(selection, selection == null ? null : paths, PreferenceUtil.INSTANCE.getSongSortOrder(), true);
|
||||
|
||||
return songCursor == null
|
||||
? null
|
||||
: new SortedCursor(songCursor, paths, Constants.DATA);
|
||||
}
|
||||
|
||||
private static String makePlaceholders(int len) {
|
||||
StringBuilder sb = new StringBuilder(len * 2 - 1);
|
||||
sb.append("?");
|
||||
for (int i = 1; i < len; i++) {
|
||||
sb.append(",?");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String[] toPathArray(@Nullable List<File> files) {
|
||||
if (files != null) {
|
||||
String[] paths = new String[files.size()];
|
||||
for (int i = 0; i < files.size(); i++) {
|
||||
paths[i] = safeGetCanonicalPath(files.get(i));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static List<File> listFiles(@NonNull File directory, @Nullable FileFilter fileFilter) {
|
||||
List<File> fileList = new LinkedList<>();
|
||||
File[] found = directory.listFiles(fileFilter);
|
||||
if (found != null) {
|
||||
Collections.addAll(fileList, found);
|
||||
}
|
||||
return fileList;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static List<File> listFilesDeep(@NonNull File directory, @Nullable FileFilter fileFilter) {
|
||||
List<File> files = new LinkedList<>();
|
||||
internalListFilesDeep(files, directory, fileFilter);
|
||||
return files;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static List<File> listFilesDeep(
|
||||
@NonNull Collection<File> files, @Nullable FileFilter fileFilter) {
|
||||
List<File> resFiles = new LinkedList<>();
|
||||
for (File file : files) {
|
||||
if (file.isDirectory()) {
|
||||
internalListFilesDeep(resFiles, file, fileFilter);
|
||||
} else if (fileFilter == null || fileFilter.accept(file)) {
|
||||
resFiles.add(file);
|
||||
}
|
||||
}
|
||||
return resFiles;
|
||||
}
|
||||
|
||||
private static void internalListFilesDeep(
|
||||
@NonNull Collection<File> files, @NonNull File directory, @Nullable FileFilter fileFilter) {
|
||||
File[] found = directory.listFiles(fileFilter);
|
||||
|
||||
if (found != null) {
|
||||
for (File file : found) {
|
||||
if (file.isDirectory()) {
|
||||
internalListFilesDeep(files, file, fileFilter);
|
||||
} else {
|
||||
files.add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean fileIsMimeType(File file, String mimeType, MimeTypeMap mimeTypeMap) {
|
||||
if (mimeType == null || mimeType.equals("*/*")) {
|
||||
return true;
|
||||
} else {
|
||||
// get the file mime type
|
||||
String filename = file.toURI().toString();
|
||||
int dotPos = filename.lastIndexOf('.');
|
||||
if (dotPos == -1) {
|
||||
return false;
|
||||
}
|
||||
String fileExtension = filename.substring(dotPos + 1).toLowerCase();
|
||||
String fileType = mimeTypeMap.getMimeTypeFromExtension(fileExtension);
|
||||
if (fileType == null) {
|
||||
return false;
|
||||
}
|
||||
// check the 'type/subtype' pattern
|
||||
if (fileType.equals(mimeType)) {
|
||||
return true;
|
||||
}
|
||||
// check the 'type/*' pattern
|
||||
int mimeTypeDelimiter = mimeType.lastIndexOf('/');
|
||||
if (mimeTypeDelimiter == -1) {
|
||||
return false;
|
||||
}
|
||||
String mimeTypeMainType = mimeType.substring(0, mimeTypeDelimiter);
|
||||
String mimeTypeSubtype = mimeType.substring(mimeTypeDelimiter + 1);
|
||||
if (!mimeTypeSubtype.equals("*")) {
|
||||
return false;
|
||||
}
|
||||
int fileTypeDelimiter = fileType.lastIndexOf('/');
|
||||
if (fileTypeDelimiter == -1) {
|
||||
return false;
|
||||
}
|
||||
String fileTypeMainType = fileType.substring(0, fileTypeDelimiter);
|
||||
if (fileTypeMainType.equals(mimeTypeMainType)) {
|
||||
return true;
|
||||
}
|
||||
return fileTypeMainType.equals(mimeTypeMainType);
|
||||
}
|
||||
}
|
||||
|
||||
public static String stripExtension(String str) {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
int pos = str.lastIndexOf('.');
|
||||
if (pos == -1) {
|
||||
return str;
|
||||
}
|
||||
return str.substring(0, pos);
|
||||
}
|
||||
|
||||
public static String readFromStream(InputStream is) throws Exception {
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append("\n");
|
||||
}
|
||||
sb.append(line);
|
||||
}
|
||||
reader.close();
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static String read(File file) throws Exception {
|
||||
FileInputStream fin = new FileInputStream(file);
|
||||
String ret = readFromStream(fin);
|
||||
fin.close();
|
||||
return ret;
|
||||
}
|
||||
|
||||
public static boolean isExternalMemoryAvailable() {
|
||||
Boolean isSDPresent =
|
||||
Environment.getExternalStorageState().equals(android.os.Environment.MEDIA_MOUNTED);
|
||||
Boolean isSDSupportedDevice = Environment.isExternalStorageRemovable();
|
||||
|
||||
// yes SD-card is present
|
||||
// Sorry
|
||||
return isSDSupportedDevice && isSDPresent;
|
||||
}
|
||||
|
||||
public static File safeGetCanonicalFile(File file) {
|
||||
try {
|
||||
return file.getCanonicalFile();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
return file.getAbsoluteFile();
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/DrKLO/Telegram/blob/ab221dafadbc17459d78d9ea3e643ae18e934b16/TMessagesProj/src/main/java/org/telegram/ui/Components/ChatAttachAlertDocumentLayout.java#L939
|
||||
public static ArrayList<Storage> listRoots() {
|
||||
ArrayList<Storage> storageItems = new ArrayList<>();
|
||||
HashSet<String> paths = new HashSet<>();
|
||||
String defaultPath = getExternalStorageDirectory().getPath();
|
||||
String defaultPathState = Environment.getExternalStorageState();
|
||||
if (defaultPathState.equals(Environment.MEDIA_MOUNTED) || defaultPathState.equals(Environment.MEDIA_MOUNTED_READ_ONLY)) {
|
||||
Storage ext = new Storage();
|
||||
if (Environment.isExternalStorageRemovable()) {
|
||||
ext.title = "SD Card";
|
||||
} else {
|
||||
ext.title = "Internal Storage";
|
||||
}
|
||||
ext.file = getExternalStorageDirectory();
|
||||
storageItems.add(ext);
|
||||
paths.add(defaultPath);
|
||||
}
|
||||
|
||||
BufferedReader bufferedReader = null;
|
||||
try {
|
||||
bufferedReader = new BufferedReader(new FileReader("/proc/mounts"));
|
||||
String line;
|
||||
while ((line = bufferedReader.readLine()) != null) {
|
||||
if (line.contains("vfat") || line.contains("/mnt")) {
|
||||
StringTokenizer tokens = new StringTokenizer(line, " ");
|
||||
tokens.nextToken();
|
||||
String path = tokens.nextToken();
|
||||
if (paths.contains(path)) {
|
||||
continue;
|
||||
}
|
||||
if (line.contains("/dev/block/vold")) {
|
||||
if (!line.contains("/mnt/secure") && !line.contains("/mnt/asec") && !line.contains("/mnt/obb") && !line.contains("/dev/mapper") && !line.contains("tmpfs")) {
|
||||
if (!new File(path).isDirectory()) {
|
||||
int index = path.lastIndexOf('/');
|
||||
if (index != -1) {
|
||||
String newPath = "/storage/" + path.substring(index + 1);
|
||||
if (new File(newPath).isDirectory()) {
|
||||
path = newPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
paths.add(path);
|
||||
try {
|
||||
Storage item = new Storage();
|
||||
if (path.toLowerCase().contains("sd")) {
|
||||
item.title = "SD Card";
|
||||
} else {
|
||||
item.title = "External Storage";
|
||||
}
|
||||
item.file = new File(path);
|
||||
storageItems.add(item);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
if (bufferedReader != null) {
|
||||
try {
|
||||
bufferedReader.close();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
return storageItems;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
|
||||
object FileUtils {
|
||||
fun copyFileToUri(context: Context, fromFile: File, toUri: Uri) {
|
||||
context.contentResolver.openOutputStream(toUri)
|
||||
?.use { output ->
|
||||
fromFile.inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* creates a new file in storage in app specific directory.
|
||||
*
|
||||
* @return the file
|
||||
* @throws IOException
|
||||
*/
|
||||
fun createFile(
|
||||
context: Context,
|
||||
directoryName: String,
|
||||
fileName: String,
|
||||
body: String,
|
||||
fileType: String
|
||||
): File {
|
||||
val root = createDirectory(context, directoryName)
|
||||
val filePath = "$root/$fileName$fileType"
|
||||
val file = File(filePath)
|
||||
|
||||
// create file if not exist
|
||||
if (!file.exists()) {
|
||||
try {
|
||||
// create a new file and write text in it.
|
||||
file.createNewFile()
|
||||
file.writeText(body)
|
||||
Log.d(FileUtils::class.java.name, "File has been created and saved")
|
||||
} catch (e: IOException) {
|
||||
Log.d(FileUtils::class.java.name, e.message.toString())
|
||||
}
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
/**
|
||||
* creates a new directory in storage in app specific directory.
|
||||
*
|
||||
* @return the file
|
||||
*/
|
||||
private fun createDirectory(context: Context, directoryName: String): File {
|
||||
val file = File(
|
||||
context.getExternalFilesDir(directoryName)
|
||||
.toString()
|
||||
)
|
||||
if (!file.exists()) {
|
||||
file.mkdir()
|
||||
}
|
||||
return file
|
||||
}
|
||||
}
|
||||
@Suppress("Deprecation")
|
||||
fun getExternalStorageDirectory(): File {
|
||||
return Environment.getExternalStorageDirectory()
|
||||
}
|
||||
|
||||
@Suppress("Deprecation")
|
||||
fun getExternalStoragePublicDirectory(type: String): File {
|
||||
return Environment.getExternalStoragePublicDirectory(type)
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
|
||||
package code.name.monkey.retromusic.util;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
/**
|
||||
* Created on : June 18, 2016 Author : zetbaitsu Name : Zetra GitHub : https://github.com/zetbaitsu
|
||||
*/
|
||||
public class ImageUtil {
|
||||
|
||||
private ImageUtil() {}
|
||||
|
||||
public static Bitmap resizeBitmap(@NonNull Bitmap src, int maxForSmallerSize) {
|
||||
int width = src.getWidth();
|
||||
int height = src.getHeight();
|
||||
|
||||
final int dstWidth;
|
||||
final int dstHeight;
|
||||
|
||||
if (width < height) {
|
||||
if (maxForSmallerSize >= width) {
|
||||
return src;
|
||||
}
|
||||
float ratio = (float) height / width;
|
||||
dstWidth = maxForSmallerSize;
|
||||
dstHeight = Math.round(maxForSmallerSize * ratio);
|
||||
} else {
|
||||
if (maxForSmallerSize >= height) {
|
||||
return src;
|
||||
}
|
||||
float ratio = (float) width / height;
|
||||
dstWidth = Math.round(maxForSmallerSize * ratio);
|
||||
dstHeight = maxForSmallerSize;
|
||||
}
|
||||
|
||||
return Bitmap.createScaledBitmap(src, dstWidth, dstHeight, false);
|
||||
}
|
||||
|
||||
public static int calculateInSampleSize(int width, int height, int reqWidth) {
|
||||
// setting reqWidth matching to desired 1:1 ratio and screen-size
|
||||
if (width < height) {
|
||||
reqWidth = (height / width) * reqWidth;
|
||||
} else {
|
||||
reqWidth = (width / height) * reqWidth;
|
||||
}
|
||||
|
||||
int inSampleSize = 1;
|
||||
|
||||
if (height > reqWidth || width > reqWidth) {
|
||||
final int halfHeight = height / 2;
|
||||
final int halfWidth = width / 2;
|
||||
|
||||
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
|
||||
// height and width larger than the requested height and width.
|
||||
while ((halfHeight / inSampleSize) > reqWidth && (halfWidth / inSampleSize) > reqWidth) {
|
||||
inSampleSize *= 2;
|
||||
}
|
||||
}
|
||||
|
||||
return inSampleSize;
|
||||
}
|
||||
}
|
174
app/src/main/java/code/name/monkey/retromusic/util/LyricUtil.kt
Normal file
174
app/src/main/java/code/name/monkey/retromusic/util/LyricUtil.kt
Normal file
|
@ -0,0 +1,174 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.util.Log
|
||||
import code.name.monkey.retromusic.model.Song
|
||||
import code.name.monkey.retromusic.model.lyrics.AbsSynchronizedLyrics
|
||||
import org.jaudiotagger.audio.AudioFileIO
|
||||
import org.jaudiotagger.tag.FieldKey
|
||||
import java.io.*
|
||||
|
||||
/**
|
||||
* Created by hefuyi on 2016/11/8.
|
||||
*/
|
||||
object LyricUtil {
|
||||
private val lrcRootPath =
|
||||
getExternalStorageDirectory().toString() + "/RetroMusic/lyrics/"
|
||||
private const val TAG = "LyricUtil"
|
||||
fun writeLrcToLoc(
|
||||
title: String, artist: String, lrcContext: String
|
||||
): File? {
|
||||
var writer: FileWriter? = null
|
||||
return try {
|
||||
val file = File(getLrcPath(title, artist))
|
||||
if (file.parentFile?.exists() != true) {
|
||||
file.parentFile?.mkdirs()
|
||||
}
|
||||
writer = FileWriter(getLrcPath(title, artist))
|
||||
writer.write(lrcContext)
|
||||
file
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
} finally {
|
||||
try {
|
||||
writer?.close()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//So in Retro, Lrc file can be same folder as Music File or in RetroMusic Folder
|
||||
// In this case we pass location of the file and Contents to write to file
|
||||
fun writeLrc(song: Song, lrcContext: String) {
|
||||
var writer: FileWriter? = null
|
||||
val location: File?
|
||||
try {
|
||||
if (isLrcOriginalFileExist(song.data)) {
|
||||
location = getLocalLyricOriginalFile(song.data)
|
||||
} else if (isLrcFileExist(song.title, song.artistName)) {
|
||||
location = getLocalLyricFile(song.title, song.artistName)
|
||||
} else {
|
||||
location = File(getLrcPath(song.title, song.artistName))
|
||||
if (location.parentFile?.exists() != true) {
|
||||
location.parentFile?.mkdirs()
|
||||
}
|
||||
}
|
||||
writer = FileWriter(location)
|
||||
writer.write(lrcContext)
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
try {
|
||||
writer?.close()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteLrcFile(title: String, artist: String): Boolean {
|
||||
val file = File(getLrcPath(title, artist))
|
||||
return file.delete()
|
||||
}
|
||||
|
||||
private fun isLrcFileExist(title: String, artist: String): Boolean {
|
||||
val file = File(getLrcPath(title, artist))
|
||||
return file.exists()
|
||||
}
|
||||
|
||||
private fun isLrcOriginalFileExist(path: String): Boolean {
|
||||
val file = File(getLrcOriginalPath(path))
|
||||
return file.exists()
|
||||
}
|
||||
|
||||
private fun getLocalLyricFile(title: String, artist: String): File? {
|
||||
val file = File(getLrcPath(title, artist))
|
||||
return if (file.exists()) {
|
||||
file
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLocalLyricOriginalFile(path: String): File? {
|
||||
val file = File(getLrcOriginalPath(path))
|
||||
return if (file.exists()) {
|
||||
file
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLrcPath(title: String, artist: String): String {
|
||||
return "$lrcRootPath$title - $artist.lrc"
|
||||
}
|
||||
|
||||
fun getLrcOriginalPath(filePath: String): String {
|
||||
return filePath.replace(filePath.substring(filePath.lastIndexOf(".") + 1), "lrc")
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
fun getStringFromFile(title: String, artist: String): String {
|
||||
val file = File(getLrcPath(title, artist))
|
||||
val fin = FileInputStream(file)
|
||||
val ret = convertStreamToString(fin)
|
||||
fin.close()
|
||||
return ret
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun convertStreamToString(inputStream: InputStream): String {
|
||||
return inputStream.bufferedReader().readLines().joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
fun getStringFromLrc(file: File?): String {
|
||||
try {
|
||||
val reader = BufferedReader(FileReader(file))
|
||||
return reader.readLines().joinToString(separator = "\n")
|
||||
} catch (e: Exception) {
|
||||
Log.i("Error", "Error Occurred")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
fun getSyncedLyricsFile(song: Song): File? {
|
||||
return when {
|
||||
isLrcOriginalFileExist(song.data) -> {
|
||||
getLocalLyricOriginalFile(song.data)
|
||||
}
|
||||
isLrcFileExist(song.title, song.artistName) -> {
|
||||
getLocalLyricFile(song.title, song.artistName)
|
||||
}
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getEmbeddedSyncedLyrics(data: String): String? {
|
||||
val embeddedLyrics = try{
|
||||
AudioFileIO.read(File(data)).tagOrCreateDefault.getFirst(FieldKey.LYRICS)
|
||||
} catch(e: Exception){
|
||||
return null
|
||||
}
|
||||
return if (AbsSynchronizedLyrics.isSynchronized(embeddedLyrics)) {
|
||||
embeddedLyrics
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import androidx.core.graphics.scale
|
||||
import com.bumptech.glide.util.Util.assertBackgroundThread
|
||||
|
||||
|
||||
internal object MergedImageUtils {
|
||||
|
||||
private const val IMAGE_SIZE = 1600
|
||||
private const val PARTS = 3
|
||||
private const val DEGREES = 9f
|
||||
|
||||
fun joinImages(list: List<Bitmap>): Bitmap {
|
||||
assertBackgroundThread()
|
||||
|
||||
val arranged = arrangeBitmaps(list)
|
||||
|
||||
val mergedImage = create(
|
||||
arranged,
|
||||
IMAGE_SIZE,
|
||||
PARTS
|
||||
)
|
||||
val finalImage = rotate(
|
||||
mergedImage,
|
||||
IMAGE_SIZE,
|
||||
DEGREES
|
||||
)
|
||||
mergedImage.recycle()
|
||||
return finalImage
|
||||
}
|
||||
|
||||
private fun arrangeBitmaps(list: List<Bitmap>): List<Bitmap> {
|
||||
return when {
|
||||
list.size == 1 -> {
|
||||
val item = list[0]
|
||||
listOf(item, item, item, item, item, item, item, item, item)
|
||||
}
|
||||
list.size == 2 -> {
|
||||
val item1 = list[0]
|
||||
val item2 = list[1]
|
||||
listOf(item1, item2, item1, item2, item1, item2, item1, item2, item1)
|
||||
}
|
||||
list.size == 3 -> {
|
||||
val item1 = list[0]
|
||||
val item2 = list[1]
|
||||
val item3 = list[2]
|
||||
listOf(item1, item2, item3, item3, item1, item2, item2, item3, item1)
|
||||
}
|
||||
list.size == 4 -> {
|
||||
val item1 = list[0]
|
||||
val item2 = list[1]
|
||||
val item3 = list[2]
|
||||
val item4 = list[3]
|
||||
listOf(item1, item2, item3, item4, item1, item2, item3, item4, item1)
|
||||
}
|
||||
list.size < 9 -> { // 5 to 8
|
||||
val item1 = list[0]
|
||||
val item2 = list[1]
|
||||
val item3 = list[2]
|
||||
val item4 = list[3]
|
||||
val item5 = list[4]
|
||||
listOf(item1, item2, item3, item4, item5, item2, item3, item4, item1)
|
||||
}
|
||||
else -> list // case 9
|
||||
}
|
||||
}
|
||||
|
||||
private fun create(images: List<Bitmap>, imageSize: Int, parts: Int): Bitmap {
|
||||
val result = Bitmap.createBitmap(imageSize, imageSize, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(result)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
val onePartSize = imageSize / parts
|
||||
|
||||
images.forEachIndexed { i, bitmap ->
|
||||
val bit = bitmap.scale(onePartSize, onePartSize)
|
||||
canvas.drawBitmap(
|
||||
bit,
|
||||
(onePartSize * (i % parts)).toFloat() + (i % 3) * 50,
|
||||
(onePartSize * (i / parts)).toFloat() + (i / 3) * 50,
|
||||
paint
|
||||
)
|
||||
bit.recycle()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun rotate(bitmap: Bitmap, imageSize: Int, degrees: Float): Bitmap {
|
||||
val matrix = Matrix()
|
||||
matrix.postRotate(degrees)
|
||||
|
||||
val rotated = Bitmap.createBitmap(bitmap, 0, 0, imageSize, imageSize, matrix, true)
|
||||
bitmap.recycle()
|
||||
val cropStart = imageSize * 25 / 100
|
||||
val cropEnd: Int = (cropStart * 1.5).toInt()
|
||||
val cropped = Bitmap.createBitmap(
|
||||
rotated,
|
||||
cropStart,
|
||||
cropStart,
|
||||
imageSize - cropEnd,
|
||||
imageSize - cropEnd
|
||||
)
|
||||
rotated.recycle()
|
||||
|
||||
return cropped
|
||||
}
|
||||
|
||||
|
||||
}
|
490
app/src/main/java/code/name/monkey/retromusic/util/MusicUtil.kt
Normal file
490
app/src/main/java/code/name/monkey/retromusic/util/MusicUtil.kt
Normal file
|
@ -0,0 +1,490 @@
|
|||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.BaseColumns
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.core.net.toUri
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import code.name.monkey.appthemehelper.util.VersionUtils
|
||||
import code.name.monkey.retromusic.Constants
|
||||
import code.name.monkey.retromusic.R
|
||||
import code.name.monkey.retromusic.db.PlaylistEntity
|
||||
import code.name.monkey.retromusic.db.SongEntity
|
||||
import code.name.monkey.retromusic.db.toSongEntity
|
||||
import code.name.monkey.retromusic.extensions.getLong
|
||||
import code.name.monkey.retromusic.extensions.showToast
|
||||
import code.name.monkey.retromusic.helper.MusicPlayerRemote.removeFromQueue
|
||||
import code.name.monkey.retromusic.model.Artist
|
||||
import code.name.monkey.retromusic.model.Song
|
||||
import code.name.monkey.retromusic.model.lyrics.AbsSynchronizedLyrics
|
||||
import code.name.monkey.retromusic.repository.Repository
|
||||
import code.name.monkey.retromusic.repository.SongRepository
|
||||
import code.name.monkey.retromusic.service.MusicService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jaudiotagger.audio.AudioFileIO
|
||||
import org.jaudiotagger.tag.FieldKey
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.regex.Pattern
|
||||
|
||||
|
||||
object MusicUtil : KoinComponent {
|
||||
fun createShareSongFileIntent(song: Song, context: Context): Intent? {
|
||||
return try {
|
||||
Intent().setAction(Intent.ACTION_SEND).putExtra(
|
||||
Intent.EXTRA_STREAM,
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
context.applicationContext.packageName,
|
||||
File(song.data)
|
||||
)
|
||||
).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION).setType("audio/*")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Intent().setAction(Intent.ACTION_SEND).putExtra(
|
||||
Intent.EXTRA_STREAM,
|
||||
getSongFileUri(song.id)
|
||||
).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION).setType("audio/*")
|
||||
}
|
||||
}
|
||||
|
||||
fun buildInfoString(string1: String?, string2: String?): String {
|
||||
if (string1.isNullOrEmpty()) {
|
||||
return if (string2.isNullOrEmpty()) "" else string2
|
||||
}
|
||||
return if (string2.isNullOrEmpty()) if (string1.isNullOrEmpty()) "" else string1 else "$string1 • $string2"
|
||||
}
|
||||
|
||||
fun createAlbumArtFile(context: Context): File {
|
||||
return File(
|
||||
createAlbumArtDir(context),
|
||||
System.currentTimeMillis().toString()
|
||||
)
|
||||
}
|
||||
|
||||
private fun createAlbumArtDir(context: Context): File {
|
||||
val albumArtDir = File(
|
||||
if (VersionUtils.hasR()) context.cacheDir else getExternalStorageDirectory(),
|
||||
"/albumthumbs/"
|
||||
)
|
||||
if (!albumArtDir.exists()) {
|
||||
albumArtDir.mkdirs()
|
||||
try {
|
||||
File(albumArtDir, ".nomedia").createNewFile()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
return albumArtDir
|
||||
}
|
||||
|
||||
fun deleteAlbumArt(context: Context, albumId: Long) {
|
||||
val contentResolver = context.contentResolver
|
||||
val localUri = "content://media/external/audio/albumart".toUri()
|
||||
contentResolver.delete(ContentUris.withAppendedId(localUri, albumId), null, null)
|
||||
contentResolver.notifyChange(localUri, null)
|
||||
}
|
||||
|
||||
fun getArtistInfoString(
|
||||
context: Context,
|
||||
artist: Artist
|
||||
): String {
|
||||
val albumCount = artist.albumCount
|
||||
val songCount = artist.songCount
|
||||
val albumString =
|
||||
if (albumCount == 1) context.resources.getString(R.string.album)
|
||||
else context.resources.getString(R.string.albums)
|
||||
val songString =
|
||||
if (songCount == 1) context.resources.getString(R.string.song)
|
||||
else context.resources.getString(R.string.songs)
|
||||
return "$albumCount $albumString • $songCount $songString"
|
||||
}
|
||||
|
||||
//iTunes uses for example 1002 for track 2 CD1 or 3011 for track 11 CD3.
|
||||
//this method converts those values to normal tracknumbers
|
||||
fun getFixedTrackNumber(trackNumberToFix: Int): Int {
|
||||
return trackNumberToFix % 1000
|
||||
}
|
||||
|
||||
fun getLyrics(song: Song): String? {
|
||||
var lyrics: String? = "No lyrics found"
|
||||
val file = File(song.data)
|
||||
try {
|
||||
lyrics = AudioFileIO.read(file).tagOrCreateDefault.getFirst(FieldKey.LYRICS)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
if (lyrics == null || lyrics.trim { it <= ' ' }.isEmpty() || AbsSynchronizedLyrics
|
||||
.isSynchronized(lyrics)
|
||||
) {
|
||||
val dir = file.absoluteFile.parentFile
|
||||
if (dir != null && dir.exists() && dir.isDirectory) {
|
||||
val format = ".*%s.*\\.(lrc|txt)"
|
||||
val filename = Pattern.quote(
|
||||
FileUtil.stripExtension(file.name)
|
||||
)
|
||||
val songtitle = Pattern.quote(song.title)
|
||||
val patterns =
|
||||
ArrayList<Pattern>()
|
||||
patterns.add(
|
||||
Pattern.compile(
|
||||
String.format(format, filename),
|
||||
Pattern.CASE_INSENSITIVE or Pattern.UNICODE_CASE
|
||||
)
|
||||
)
|
||||
patterns.add(
|
||||
Pattern.compile(
|
||||
String.format(format, songtitle),
|
||||
Pattern.CASE_INSENSITIVE or Pattern.UNICODE_CASE
|
||||
)
|
||||
)
|
||||
val files =
|
||||
dir.listFiles { f: File ->
|
||||
for (pattern in patterns) {
|
||||
if (pattern.matcher(f.name).matches()) {
|
||||
return@listFiles true
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
if (files != null && files.isNotEmpty()) {
|
||||
for (f in files) {
|
||||
try {
|
||||
val newLyrics =
|
||||
FileUtil.read(f)
|
||||
if (newLyrics != null && newLyrics.trim { it <= ' ' }.isNotEmpty()) {
|
||||
if (AbsSynchronizedLyrics.isSynchronized(newLyrics)) {
|
||||
return newLyrics
|
||||
}
|
||||
lyrics = newLyrics
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return lyrics
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getMediaStoreAlbumCoverUri(albumId: Long): Uri {
|
||||
val sArtworkUri = "content://media/external/audio/albumart".toUri()
|
||||
return ContentUris.withAppendedId(sArtworkUri, albumId)
|
||||
}
|
||||
|
||||
|
||||
fun getPlaylistInfoString(
|
||||
context: Context,
|
||||
songs: List<Song>
|
||||
): String {
|
||||
val duration = getTotalDuration(songs)
|
||||
return buildInfoString(
|
||||
getSongCountString(context, songs.size),
|
||||
getReadableDurationString(duration)
|
||||
)
|
||||
}
|
||||
|
||||
fun playlistInfoString(
|
||||
context: Context,
|
||||
songs: List<SongEntity>
|
||||
): String {
|
||||
return getSongCountString(context, songs.size)
|
||||
}
|
||||
|
||||
fun getReadableDurationString(songDurationMillis: Long): String {
|
||||
var minutes = songDurationMillis / 1000 / 60
|
||||
val seconds = songDurationMillis / 1000 % 60
|
||||
return if (minutes < 60) {
|
||||
String.format(
|
||||
Locale.getDefault(),
|
||||
"%02d:%02d",
|
||||
minutes,
|
||||
seconds
|
||||
)
|
||||
} else {
|
||||
val hours = minutes / 60
|
||||
minutes %= 60
|
||||
String.format(
|
||||
Locale.getDefault(),
|
||||
"%02d:%02d:%02d",
|
||||
hours,
|
||||
minutes,
|
||||
seconds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getSectionName(mediaTitle: String?): String {
|
||||
var musicMediaTitle = mediaTitle
|
||||
return try {
|
||||
if (musicMediaTitle.isNullOrEmpty()) {
|
||||
return "-"
|
||||
}
|
||||
musicMediaTitle = musicMediaTitle.trim { it <= ' ' }.lowercase()
|
||||
if (musicMediaTitle.startsWith("the ")) {
|
||||
musicMediaTitle = musicMediaTitle.substring(4)
|
||||
} else if (musicMediaTitle.startsWith("a ")) {
|
||||
musicMediaTitle = musicMediaTitle.substring(2)
|
||||
}
|
||||
if (musicMediaTitle.isEmpty()) {
|
||||
""
|
||||
} else musicMediaTitle.substring(0, 1).uppercase()
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
fun getSongCountString(context: Context, songCount: Int): String {
|
||||
val songString = if (songCount == 1) context.resources
|
||||
.getString(R.string.song) else context.resources.getString(R.string.songs)
|
||||
return "$songCount $songString"
|
||||
}
|
||||
|
||||
fun getSongFileUri(songId: Long): Uri {
|
||||
return ContentUris.withAppendedId(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
songId
|
||||
)
|
||||
}
|
||||
|
||||
fun getSongFilePath(context: Context, uri: Uri): String {
|
||||
val projection = arrayOf(Constants.DATA)
|
||||
context.contentResolver.query(uri, projection, null, null, null)?.use {
|
||||
if (it.moveToFirst()) {
|
||||
return it.getString(0)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
fun getTotalDuration(songs: List<Song>): Long {
|
||||
var duration: Long = 0
|
||||
for (i in songs.indices) {
|
||||
duration += songs[i].duration
|
||||
}
|
||||
return duration
|
||||
}
|
||||
|
||||
fun getYearString(year: Int): String {
|
||||
return if (year > 0) year.toString() else "-"
|
||||
}
|
||||
|
||||
fun indexOfSongInList(songs: List<Song>, songId: Long): Int {
|
||||
return songs.indexOfFirst { it.id == songId }
|
||||
}
|
||||
|
||||
fun getDateModifiedString(date: Long): String {
|
||||
val calendar: Calendar = Calendar.getInstance()
|
||||
val pattern = "dd/MM/yyyy hh:mm:ss"
|
||||
calendar.timeInMillis = date
|
||||
val formatter = SimpleDateFormat(pattern, Locale.ENGLISH)
|
||||
return formatter.format(calendar.time)
|
||||
}
|
||||
|
||||
fun insertAlbumArt(
|
||||
context: Context,
|
||||
albumId: Long,
|
||||
path: String?
|
||||
) {
|
||||
val contentResolver = context.contentResolver
|
||||
val artworkUri = "content://media/external/audio/albumart".toUri()
|
||||
contentResolver.delete(ContentUris.withAppendedId(artworkUri, albumId), null, null)
|
||||
val values = contentValuesOf(
|
||||
"album_id" to albumId,
|
||||
"_data" to path
|
||||
)
|
||||
contentResolver.insert(artworkUri, values)
|
||||
contentResolver.notifyChange(artworkUri, null)
|
||||
}
|
||||
|
||||
fun isArtistNameUnknown(artistName: String?): Boolean {
|
||||
if (artistName.isNullOrEmpty()) {
|
||||
return false
|
||||
}
|
||||
if (artistName == Artist.UNKNOWN_ARTIST_DISPLAY_NAME) {
|
||||
return true
|
||||
}
|
||||
val tempName = artistName.trim { it <= ' ' }.lowercase()
|
||||
return tempName == "unknown" || tempName == "<unknown>"
|
||||
}
|
||||
|
||||
fun isVariousArtists(artistName: String?): Boolean {
|
||||
if (artistName.isNullOrEmpty()) {
|
||||
return false
|
||||
}
|
||||
if (artistName == Artist.VARIOUS_ARTISTS_DISPLAY_NAME) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
val repository = get<Repository>()
|
||||
fun toggleFavorite(context: Context, song: Song) {
|
||||
GlobalScope.launch {
|
||||
val playlist: PlaylistEntity = repository.favoritePlaylist()
|
||||
val songEntity = song.toSongEntity(playlist.playListId)
|
||||
val isFavorite = repository.isFavoriteSong(songEntity).isNotEmpty()
|
||||
if (isFavorite) {
|
||||
repository.removeSongFromPlaylist(songEntity)
|
||||
} else {
|
||||
repository.insertSongs(listOf(song.toSongEntity(playlist.playListId)))
|
||||
}
|
||||
context.sendBroadcast(Intent(MusicService.FAVORITE_STATE_CHANGED))
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteTracks(
|
||||
activity: FragmentActivity,
|
||||
songs: List<Song>,
|
||||
safUris: List<Uri>?,
|
||||
callback: Runnable?
|
||||
) {
|
||||
val songRepository: SongRepository = get()
|
||||
val projection = arrayOf(
|
||||
BaseColumns._ID, Constants.DATA
|
||||
)
|
||||
// Split the query into multiple batches, and merge the resulting cursors
|
||||
var batchStart: Int
|
||||
var batchEnd = 0
|
||||
val batchSize =
|
||||
1000000 / 10 // 10^6 being the SQLite limite on the query lenth in bytes, 10 being the max number of digits in an int, used to store the track ID
|
||||
val songCount = songs.size
|
||||
|
||||
while (batchEnd < songCount) {
|
||||
batchStart = batchEnd
|
||||
|
||||
val selection = StringBuilder()
|
||||
selection.append(BaseColumns._ID + " IN (")
|
||||
|
||||
var i = 0
|
||||
while (i < batchSize - 1 && batchEnd < songCount - 1) {
|
||||
selection.append(songs[batchEnd].id)
|
||||
selection.append(",")
|
||||
i++
|
||||
batchEnd++
|
||||
}
|
||||
// The last element of a batch
|
||||
// The last element of a batch
|
||||
selection.append(songs[batchEnd].id)
|
||||
batchEnd++
|
||||
selection.append(")")
|
||||
|
||||
try {
|
||||
val cursor = activity.contentResolver.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, selection.toString(),
|
||||
null, null
|
||||
)
|
||||
if (cursor != null) {
|
||||
// Step 1: Remove selected tracks from the current playlist, as well
|
||||
// as from the album art cache
|
||||
cursor.moveToFirst()
|
||||
while (!cursor.isAfterLast) {
|
||||
val id = cursor.getLong(BaseColumns._ID)
|
||||
val song: Song = songRepository.song(id)
|
||||
removeFromQueue(song)
|
||||
cursor.moveToNext()
|
||||
}
|
||||
|
||||
// Step 2: Remove selected tracks from the database
|
||||
activity.contentResolver.delete(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
selection.toString(), null
|
||||
)
|
||||
// Step 3: Remove files from card
|
||||
cursor.moveToFirst()
|
||||
var index = batchStart
|
||||
while (!cursor.isAfterLast) {
|
||||
val name = cursor.getString(1)
|
||||
val safUri =
|
||||
if (safUris == null || safUris.size <= index) null else safUris[index]
|
||||
SAFUtil.delete(activity, name, safUri)
|
||||
index++
|
||||
cursor.moveToNext()
|
||||
}
|
||||
cursor.close()
|
||||
}
|
||||
} catch (ignored: SecurityException) {
|
||||
|
||||
}
|
||||
activity.contentResolver.notifyChange("content://media".toUri(), null)
|
||||
activity.runOnUiThread {
|
||||
activity.showToast(activity.getString(R.string.deleted_x_songs, songCount))
|
||||
callback?.run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteTracks(context: Context, songs: List<Song>) {
|
||||
val projection = arrayOf(BaseColumns._ID, Constants.DATA)
|
||||
val selection = StringBuilder()
|
||||
selection.append(BaseColumns._ID + " IN (")
|
||||
for (i in songs.indices) {
|
||||
selection.append(songs[i].id)
|
||||
if (i < songs.size - 1) {
|
||||
selection.append(",")
|
||||
}
|
||||
}
|
||||
selection.append(")")
|
||||
var deletedCount = 0
|
||||
try {
|
||||
val cursor: Cursor? = context.contentResolver.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, selection.toString(),
|
||||
null, null
|
||||
)
|
||||
if (cursor != null) {
|
||||
removeFromQueue(songs)
|
||||
|
||||
// Step 2: Remove files from card
|
||||
cursor.moveToFirst()
|
||||
while (!cursor.isAfterLast) {
|
||||
val id: Int = cursor.getInt(0)
|
||||
val name: String = cursor.getString(1)
|
||||
try { // File.delete can throw a security exception
|
||||
if (SAFUtil.delete(context, name, null)) {
|
||||
// Step 3: Remove selected track from the database
|
||||
context.contentResolver.delete(
|
||||
ContentUris.withAppendedId(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
id.toLong()
|
||||
), null, null
|
||||
)
|
||||
deletedCount++
|
||||
} else {
|
||||
Log.e("MusicUtils", "Failed to delete file $name")
|
||||
}
|
||||
cursor.moveToNext()
|
||||
} catch (ex: SecurityException) {
|
||||
cursor.moveToNext()
|
||||
} catch (e: NullPointerException) {
|
||||
Log.e("MusicUtils", "Failed to find file $name")
|
||||
}
|
||||
}
|
||||
cursor.close()
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
context.showToast(context.getString(R.string.deleted_x_songs, deletedCount))
|
||||
}
|
||||
|
||||
} catch (ignored: SecurityException) {
|
||||
}
|
||||
}
|
||||
|
||||
fun songByGenre(genreId: Long): Song {
|
||||
return repository.getSongByGenre(genreId)
|
||||
}
|
||||
}
|
71
app/src/main/java/code/name/monkey/retromusic/util/NavigationUtil.kt
Executable file
71
app/src/main/java/code/name/monkey/retromusic/util/NavigationUtil.kt
Executable file
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.media.audiofx.AudioEffect
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import code.name.monkey.retromusic.R
|
||||
import code.name.monkey.retromusic.activities.*
|
||||
import code.name.monkey.retromusic.activities.bugreport.BugReportActivity
|
||||
import code.name.monkey.retromusic.extensions.showToast
|
||||
import code.name.monkey.retromusic.helper.MusicPlayerRemote.audioSessionId
|
||||
|
||||
object NavigationUtil {
|
||||
fun bugReport(activity: Activity) {
|
||||
activity.startActivity(
|
||||
Intent(activity, BugReportActivity::class.java), null
|
||||
)
|
||||
}
|
||||
|
||||
fun goToOpenSource(activity: Activity) {
|
||||
activity.startActivity(
|
||||
Intent(activity, code.name.monkey.retromusic.activities.LicenseActivity::class.java), null
|
||||
)
|
||||
}
|
||||
|
||||
fun gotoDriveMode(activity: Activity) {
|
||||
activity.startActivity(
|
||||
Intent(activity, code.name.monkey.retromusic.activities.DriveModeActivity::class.java), null
|
||||
)
|
||||
}
|
||||
|
||||
fun gotoWhatNews(activity: FragmentActivity) {
|
||||
val changelogBottomSheet = WhatsNewFragment()
|
||||
changelogBottomSheet.show(activity.supportFragmentManager, WhatsNewFragment.TAG)
|
||||
}
|
||||
|
||||
fun openEqualizer(activity: Activity) {
|
||||
stockEqualizer(activity)
|
||||
}
|
||||
|
||||
private fun stockEqualizer(activity: Activity) {
|
||||
val sessionId = audioSessionId
|
||||
if (sessionId == AudioEffect.ERROR_BAD_VALUE) {
|
||||
activity.showToast(R.string.no_audio_ID, Toast.LENGTH_LONG)
|
||||
} else {
|
||||
try {
|
||||
val effects = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
|
||||
effects.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, sessionId)
|
||||
effects.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
|
||||
activity.startActivityForResult(effects, 0)
|
||||
} catch (notFound: ActivityNotFoundException) {
|
||||
activity.showToast(R.string.no_equalizer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,350 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
|
||||
package code.name.monkey.retromusic.util
|
||||
|
||||
|
||||
import android.Manifest.permission.BIND_NOTIFICATION_LISTENER_SERVICE
|
||||
import android.Manifest.permission.MEDIA_CONTENT_CONTROL
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.XmlResourceParser
|
||||
import android.os.Process
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.annotation.XmlRes
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import code.name.monkey.retromusic.BuildConfig
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Validates that the calling package is authorized to browse a [MediaBrowserServiceCompat].
|
||||
*
|
||||
* The list of allowed signing certificates and their corresponding package names is defined in
|
||||
* res/xml/allowed_media_browser_callers.xml.
|
||||
*
|
||||
* If you want to add a new caller to allowed_media_browser_callers.xml and you don't know
|
||||
* its signature, this class will print to logcat (INFO level) a message with the proper
|
||||
* xml tags to add to allow the caller.
|
||||
*
|
||||
* For more information, see res/xml/allowed_media_browser_callers.xml.
|
||||
*/
|
||||
class PackageValidator(
|
||||
context: Context,
|
||||
@XmlRes xmlResId: Int
|
||||
) {
|
||||
private val context: Context
|
||||
private val packageManager: PackageManager
|
||||
|
||||
private val certificateWhitelist: Map<String, KnownCallerInfo>
|
||||
private val platformSignature: String
|
||||
|
||||
private val callerChecked = mutableMapOf<String, Pair<Int, Boolean>>()
|
||||
|
||||
init {
|
||||
val parser = context.resources.getXml(xmlResId)
|
||||
this.context = context.applicationContext
|
||||
this.packageManager = this.context.packageManager
|
||||
|
||||
certificateWhitelist = buildCertificateWhitelist(parser)
|
||||
platformSignature = getSystemSignature()
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the caller attempting to connect to a [MediaBrowserServiceCompat] is known.
|
||||
* See [MediaBrowserServiceCompat.onGetRoot] for where this is utilized.
|
||||
*
|
||||
* @param callingPackage The package name of the caller.
|
||||
* @param callingUid The user id of the caller.
|
||||
* @return `true` if the caller is known, `false` otherwise.
|
||||
*/
|
||||
fun isKnownCaller(callingPackage: String, callingUid: Int): Boolean {
|
||||
// If the caller has already been checked, return the previous result here.
|
||||
val (checkedUid, checkResult) = callerChecked[callingPackage] ?: Pair(0, false)
|
||||
if (checkedUid == callingUid) {
|
||||
return checkResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Because some of these checks can be slow, we save the results in [callerChecked] after
|
||||
* this code is run.
|
||||
*
|
||||
* In particular, there's little reason to recompute the calling package's certificate
|
||||
* signature (SHA-256) each call.
|
||||
*
|
||||
* This is safe to do as we know the UID matches the package's UID (from the check above),
|
||||
* and app UIDs are set at install time. Additionally, a package name + UID is guaranteed to
|
||||
* be constant until a reboot. (After a reboot then a previously assigned UID could be
|
||||
* reassigned.)
|
||||
*/
|
||||
|
||||
// Build the caller info for the rest of the checks here.
|
||||
val callerPackageInfo = buildCallerInfo(callingPackage)
|
||||
?: throw IllegalStateException("Caller wasn't found in the system?")
|
||||
|
||||
// Verify that things aren't ... broken. (This test should always pass.)
|
||||
if (callerPackageInfo.uid != callingUid) {
|
||||
throw IllegalStateException("Caller's package UID doesn't match caller's actual UID?")
|
||||
}
|
||||
|
||||
val callerSignature = callerPackageInfo.signature
|
||||
val isPackageInWhitelist = certificateWhitelist[callingPackage]?.signatures?.first {
|
||||
it.signature == callerSignature
|
||||
} != null
|
||||
|
||||
val isCallerKnown = when {
|
||||
// If it's our own app making the call, allow it.
|
||||
callingUid == Process.myUid() -> true
|
||||
// If it's one of the apps on the whitelist, allow it.
|
||||
isPackageInWhitelist -> true
|
||||
// If the system is making the call, allow it.
|
||||
callingUid == Process.SYSTEM_UID -> true
|
||||
// If the app was signed by the same certificate as the platform itself, also allow it.
|
||||
callerSignature == platformSignature -> true
|
||||
/**
|
||||
* [MEDIA_CONTENT_CONTROL] permission is only available to system applications, and
|
||||
* while it isn't required to allow these apps to connect to a
|
||||
* [MediaBrowserServiceCompat], allowing this ensures optimal compatability with apps
|
||||
* such as Android TV and the Google Assistant.
|
||||
*/
|
||||
callerPackageInfo.permissions.contains(MEDIA_CONTENT_CONTROL) -> true
|
||||
/**
|
||||
* This last permission can be specifically granted to apps, and, in addition to
|
||||
* allowing them to retrieve notifications, it also allows them to connect to an
|
||||
* active [MediaSessionCompat].
|
||||
* As with the above, it's not required to allow apps holding this permission to
|
||||
* connect to your [MediaBrowserServiceCompat], but it does allow easy comparability
|
||||
* with apps such as Wear OS.
|
||||
*/
|
||||
callerPackageInfo.permissions.contains(BIND_NOTIFICATION_LISTENER_SERVICE) -> true
|
||||
// If none of the pervious checks succeeded, then the caller is unrecognized.
|
||||
else -> false
|
||||
}
|
||||
|
||||
if (!isCallerKnown) {
|
||||
logUnknownCaller(callerPackageInfo)
|
||||
}
|
||||
|
||||
// Save our work for next time.
|
||||
callerChecked[callingPackage] = Pair(callingUid, isCallerKnown)
|
||||
return isCallerKnown
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an info level message with details of how to add a caller to the allowed callers list
|
||||
* when the app is debuggable.
|
||||
*/
|
||||
private fun logUnknownCaller(callerPackageInfo: CallerPackageInfo) {
|
||||
if (BuildConfig.DEBUG && callerPackageInfo.signature != null) {
|
||||
Log.i(TAG, "PackageValidator call" + callerPackageInfo.name + callerPackageInfo.packageName + callerPackageInfo.signature)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [CallerPackageInfo] for a given package that can be used for all the
|
||||
* various checks that are performed before allowing an app to connect to a
|
||||
* [MediaBrowserServiceCompat].
|
||||
*/
|
||||
private fun buildCallerInfo(callingPackage: String): CallerPackageInfo? {
|
||||
val packageInfo = getPackageInfo(callingPackage) ?: return null
|
||||
|
||||
val appName = packageInfo.applicationInfo.loadLabel(packageManager).toString()
|
||||
val uid = packageInfo.applicationInfo.uid
|
||||
val signature = getSignature(packageInfo)
|
||||
|
||||
val requestedPermissions = packageInfo.requestedPermissions
|
||||
val permissionFlags = packageInfo.requestedPermissionsFlags
|
||||
val activePermissions = mutableSetOf<String>()
|
||||
requestedPermissions?.forEachIndexed { index, permission ->
|
||||
if (permissionFlags[index] and REQUESTED_PERMISSION_GRANTED != 0) {
|
||||
activePermissions += permission
|
||||
}
|
||||
}
|
||||
|
||||
return CallerPackageInfo(appName, callingPackage, uid, signature, activePermissions.toSet())
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the [PackageInfo] for a package name.
|
||||
* This requests both the signatures (for checking if an app is on the whitelist) and
|
||||
* the app's permissions, which allow for more flexibility in the whitelist.
|
||||
*
|
||||
* @return [PackageInfo] for the package name or null if it's not found.
|
||||
*/
|
||||
@Suppress("Deprecation")
|
||||
@SuppressLint("PackageManagerGetSignatures")
|
||||
private fun getPackageInfo(callingPackage: String): PackageInfo? =
|
||||
packageManager.getPackageInfo(callingPackage,
|
||||
PackageManager.GET_SIGNATURES or PackageManager.GET_PERMISSIONS)
|
||||
|
||||
/**
|
||||
* Gets the signature of a given package's [PackageInfo].
|
||||
*
|
||||
* The "signature" is a SHA-256 hash of the public key of the signing certificate used by
|
||||
* the app.
|
||||
*
|
||||
* If the app is not found, or if the app does not have exactly one signature, this method
|
||||
* returns `null` as the signature.
|
||||
*/
|
||||
private fun getSignature(packageInfo: PackageInfo): String? {
|
||||
// Security best practices dictate that an app should be signed with exactly one (1)
|
||||
// signature. Because of this, if there are multiple signatures, reject it.
|
||||
return if (packageInfo.signatures == null || packageInfo.signatures.size != 1) {
|
||||
null
|
||||
} else {
|
||||
val certificate = packageInfo.signatures[0].toByteArray()
|
||||
getSignatureSha256(certificate)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCertificateWhitelist(parser: XmlResourceParser): Map<String, KnownCallerInfo> {
|
||||
|
||||
val certificateWhitelist = LinkedHashMap<String, KnownCallerInfo>()
|
||||
try {
|
||||
var eventType = parser.next()
|
||||
while (eventType != XmlResourceParser.END_DOCUMENT) {
|
||||
if (eventType == XmlResourceParser.START_TAG) {
|
||||
val callerInfo = when (parser.name) {
|
||||
"signing_certificate" -> parseV1Tag(parser)
|
||||
"signature" -> parseV2Tag(parser)
|
||||
else -> null
|
||||
}
|
||||
|
||||
callerInfo?.let { info ->
|
||||
val packageName = info.packageName
|
||||
val existingCallerInfo = certificateWhitelist[packageName]
|
||||
if (existingCallerInfo != null) {
|
||||
existingCallerInfo.signatures += callerInfo.signatures
|
||||
} else {
|
||||
certificateWhitelist[packageName] = callerInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventType = parser.next()
|
||||
}
|
||||
} catch (xmlException: XmlPullParserException) {
|
||||
Log.e(TAG, "Could not read allowed callers from XML.", xmlException)
|
||||
} catch (ioException: IOException) {
|
||||
Log.e(TAG, "Could not read allowed callers from XML.", ioException)
|
||||
}
|
||||
|
||||
return certificateWhitelist
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a v1 format tag. See allowed_media_browser_callers.xml for more details.
|
||||
*/
|
||||
private fun parseV1Tag(parser: XmlResourceParser): KnownCallerInfo {
|
||||
val name = parser.getAttributeValue(null, "name")
|
||||
val packageName = parser.getAttributeValue(null, "package")
|
||||
val isRelease = parser.getAttributeBooleanValue(null, "release", false)
|
||||
val certificate = parser.nextText().replace(WHITESPACE_REGEX, "")
|
||||
val signature = getSignatureSha256(certificate)
|
||||
|
||||
val callerSignature = KnownSignature(signature, isRelease)
|
||||
return KnownCallerInfo(name, packageName, mutableSetOf(callerSignature))
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a v2 format tag. See allowed_media_browser_callers.xml for more details.
|
||||
*/
|
||||
private fun parseV2Tag(parser: XmlResourceParser): KnownCallerInfo {
|
||||
val name = parser.getAttributeValue(null, "name")
|
||||
val packageName = parser.getAttributeValue(null, "package")
|
||||
|
||||
val callerSignatures = mutableSetOf<KnownSignature>()
|
||||
var eventType = parser.next()
|
||||
while (eventType != XmlResourceParser.END_TAG) {
|
||||
val isRelease = parser.getAttributeBooleanValue(null, "release", false)
|
||||
val signature = parser.nextText().replace(WHITESPACE_REGEX, "")
|
||||
.lowercase(Locale.getDefault())
|
||||
callerSignatures += KnownSignature(signature, isRelease)
|
||||
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
return KnownCallerInfo(name, packageName, callerSignatures)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the Android platform signing key signature. This key is never null.
|
||||
*/
|
||||
private fun getSystemSignature(): String =
|
||||
getPackageInfo(ANDROID_PLATFORM)?.let { platformInfo ->
|
||||
getSignature(platformInfo)
|
||||
} ?: throw IllegalStateException("Platform signature not found")
|
||||
|
||||
/**
|
||||
* Creates a SHA-256 signature given a Base64 encoded certificate.
|
||||
*/
|
||||
private fun getSignatureSha256(certificate: String): String {
|
||||
return getSignatureSha256(Base64.decode(certificate, Base64.DEFAULT))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SHA-256 signature given a certificate byte array.
|
||||
*/
|
||||
private fun getSignatureSha256(certificate: ByteArray): String {
|
||||
val md: MessageDigest
|
||||
try {
|
||||
md = MessageDigest.getInstance("SHA256")
|
||||
} catch (noSuchAlgorithmException: NoSuchAlgorithmException) {
|
||||
Log.e(TAG, "No such algorithm: $noSuchAlgorithmException")
|
||||
throw RuntimeException("Could not find SHA256 hash algorithm", noSuchAlgorithmException)
|
||||
}
|
||||
md.update(certificate)
|
||||
|
||||
// This code takes the byte array generated by `md.digest()` and joins each of the bytes
|
||||
// to a string, applying the string format `%02x` on each digit before it's appended, with
|
||||
// a colon (':') between each of the items.
|
||||
// For example: input=[0,2,4,6,8,10,12], output="00:02:04:06:08:0a:0c"
|
||||
return md.digest().joinToString(":") { String.format("%02x", it) }
|
||||
}
|
||||
|
||||
private data class KnownCallerInfo(
|
||||
val name: String,
|
||||
val packageName: String,
|
||||
val signatures: MutableSet<KnownSignature>
|
||||
)
|
||||
|
||||
private data class KnownSignature(
|
||||
val signature: String,
|
||||
val release: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
* Convenience class to hold all of the information about an app that's being checked
|
||||
* to see if it's a known caller.
|
||||
*/
|
||||
private data class CallerPackageInfo(
|
||||
val name: String,
|
||||
val packageName: String,
|
||||
val uid: Int,
|
||||
val signature: String?,
|
||||
val permissions: Set<String>
|
||||
)
|
||||
}
|
||||
|
||||
private const val TAG = "PackageValidator"
|
||||
private const val ANDROID_PLATFORM = "android"
|
||||
private val WHITESPACE_REGEX = "\\s|\\n".toRegex()
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
package code.name.monkey.retromusic.util
|
||||
|
||||
import code.name.monkey.retromusic.db.PlaylistWithSongs
|
||||
import code.name.monkey.retromusic.helper.M3UWriter.writeIO
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
object PlaylistsUtil {
|
||||
@Throws(IOException::class)
|
||||
fun savePlaylistWithSongs(playlist: PlaylistWithSongs?): File {
|
||||
return writeIO(
|
||||
File(getExternalStorageDirectory(), "Playlists"), playlist!!
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,714 @@
|
|||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.content.res.use
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.viewpager.widget.ViewPager
|
||||
import code.name.monkey.appthemehelper.util.VersionUtils
|
||||
import code.name.monkey.retromusic.*
|
||||
import code.name.monkey.retromusic.extensions.getIntRes
|
||||
import code.name.monkey.retromusic.extensions.getStringOrDefault
|
||||
import code.name.monkey.retromusic.fragments.AlbumCoverStyle
|
||||
import code.name.monkey.retromusic.fragments.GridStyle
|
||||
import code.name.monkey.retromusic.fragments.NowPlayingScreen
|
||||
import code.name.monkey.retromusic.fragments.folder.FoldersFragment
|
||||
import code.name.monkey.retromusic.helper.SortOrder.*
|
||||
import code.name.monkey.retromusic.transform.*
|
||||
import code.name.monkey.retromusic.model.CategoryInfo
|
||||
import code.name.monkey.retromusic.util.theme.ThemeMode
|
||||
import code.name.monkey.retromusic.views.TopAppBarLayout
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonSyntaxException
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.io.File
|
||||
|
||||
|
||||
object PreferenceUtil {
|
||||
private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(App.getContext())
|
||||
|
||||
val defaultCategories = listOf(
|
||||
CategoryInfo(CategoryInfo.Category.Home, true),
|
||||
CategoryInfo(CategoryInfo.Category.Songs, true),
|
||||
CategoryInfo(CategoryInfo.Category.Albums, true),
|
||||
CategoryInfo(CategoryInfo.Category.Artists, true),
|
||||
CategoryInfo(CategoryInfo.Category.Playlists, true),
|
||||
CategoryInfo(CategoryInfo.Category.Genres, false),
|
||||
CategoryInfo(CategoryInfo.Category.Folder, false),
|
||||
CategoryInfo(CategoryInfo.Category.Search, false)
|
||||
)
|
||||
|
||||
var libraryCategory: List<CategoryInfo>
|
||||
get() {
|
||||
val gson = Gson()
|
||||
val collectionType = object : TypeToken<List<CategoryInfo>>() {}.type
|
||||
|
||||
val data = sharedPreferences.getStringOrDefault(
|
||||
LIBRARY_CATEGORIES,
|
||||
gson.toJson(defaultCategories, collectionType)
|
||||
)
|
||||
return try {
|
||||
Gson().fromJson(data, collectionType)
|
||||
} catch (e: JsonSyntaxException) {
|
||||
e.printStackTrace()
|
||||
return defaultCategories
|
||||
}
|
||||
}
|
||||
set(value) {
|
||||
val collectionType = object : TypeToken<List<CategoryInfo?>?>() {}.type
|
||||
sharedPreferences.edit {
|
||||
putString(LIBRARY_CATEGORIES, Gson().toJson(value, collectionType))
|
||||
}
|
||||
}
|
||||
|
||||
fun registerOnSharedPreferenceChangedListener(
|
||||
listener: OnSharedPreferenceChangeListener,
|
||||
) = sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
|
||||
|
||||
|
||||
fun unregisterOnSharedPreferenceChangedListener(
|
||||
changeListener: OnSharedPreferenceChangeListener,
|
||||
) = sharedPreferences.unregisterOnSharedPreferenceChangeListener(changeListener)
|
||||
|
||||
|
||||
val baseTheme get() = sharedPreferences.getStringOrDefault(GENERAL_THEME, "auto")
|
||||
|
||||
fun getGeneralThemeValue(isSystemDark: Boolean): ThemeMode {
|
||||
val themeMode: String =
|
||||
sharedPreferences.getStringOrDefault(GENERAL_THEME, "auto")
|
||||
return if (isBlackMode && isSystemDark && themeMode != "light") {
|
||||
ThemeMode.BLACK
|
||||
} else {
|
||||
if (isBlackMode && themeMode == "dark") {
|
||||
ThemeMode.BLACK
|
||||
} else {
|
||||
when (themeMode) {
|
||||
"light" -> ThemeMode.LIGHT
|
||||
"dark" -> ThemeMode.DARK
|
||||
"auto" -> ThemeMode.AUTO
|
||||
else -> ThemeMode.AUTO
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val languageCode: String get() = sharedPreferences.getString(LANGUAGE_NAME, "auto") ?: "auto"
|
||||
|
||||
var Fragment.userName
|
||||
get() = sharedPreferences.getString(
|
||||
USER_NAME,
|
||||
getString(R.string.user_name)
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putString(USER_NAME, value)
|
||||
}
|
||||
|
||||
var safSdCardUri
|
||||
get() = sharedPreferences.getStringOrDefault(SAF_SDCARD_URI, "")
|
||||
set(value) = sharedPreferences.edit {
|
||||
putString(SAF_SDCARD_URI, value)
|
||||
}
|
||||
|
||||
var albumArtistsOnly
|
||||
get() = sharedPreferences.getBoolean(
|
||||
ALBUM_ARTISTS_ONLY,
|
||||
false
|
||||
)
|
||||
set(value) = sharedPreferences.edit { putBoolean(ALBUM_ARTISTS_ONLY, value) }
|
||||
|
||||
var albumDetailSongSortOrder
|
||||
get() = sharedPreferences.getStringOrDefault(
|
||||
ALBUM_DETAIL_SONG_SORT_ORDER,
|
||||
AlbumSongSortOrder.SONG_TRACK_LIST
|
||||
)
|
||||
set(value) = sharedPreferences.edit { putString(ALBUM_DETAIL_SONG_SORT_ORDER, value) }
|
||||
|
||||
var artistDetailSongSortOrder
|
||||
get() = sharedPreferences.getStringOrDefault(
|
||||
ARTIST_DETAIL_SONG_SORT_ORDER,
|
||||
ArtistSongSortOrder.SONG_A_Z
|
||||
)
|
||||
set(value) = sharedPreferences.edit { putString(ARTIST_DETAIL_SONG_SORT_ORDER, value) }
|
||||
|
||||
var songSortOrder
|
||||
get() = sharedPreferences.getStringOrDefault(
|
||||
SONG_SORT_ORDER,
|
||||
SongSortOrder.SONG_A_Z
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putString(SONG_SORT_ORDER, value)
|
||||
}
|
||||
|
||||
var albumSortOrder
|
||||
get() = sharedPreferences.getStringOrDefault(
|
||||
ALBUM_SORT_ORDER,
|
||||
AlbumSortOrder.ALBUM_A_Z
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putString(ALBUM_SORT_ORDER, value)
|
||||
}
|
||||
|
||||
|
||||
var artistSortOrder
|
||||
get() = sharedPreferences.getStringOrDefault(
|
||||
ARTIST_SORT_ORDER,
|
||||
ArtistSortOrder.ARTIST_A_Z
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putString(ARTIST_SORT_ORDER, value)
|
||||
}
|
||||
|
||||
val albumSongSortOrder
|
||||
get() = sharedPreferences.getStringOrDefault(
|
||||
ALBUM_SONG_SORT_ORDER,
|
||||
AlbumSongSortOrder.SONG_TRACK_LIST
|
||||
)
|
||||
|
||||
val artistSongSortOrder
|
||||
get() = sharedPreferences.getStringOrDefault(
|
||||
ARTIST_SONG_SORT_ORDER,
|
||||
AlbumSongSortOrder.SONG_TRACK_LIST
|
||||
)
|
||||
|
||||
val artistAlbumSortOrder
|
||||
get() = sharedPreferences.getStringOrDefault(
|
||||
ARTIST_ALBUM_SORT_ORDER,
|
||||
ArtistAlbumSortOrder.ALBUM_A_Z
|
||||
)
|
||||
|
||||
var playlistSortOrder
|
||||
get() = sharedPreferences.getStringOrDefault(
|
||||
PLAYLIST_SORT_ORDER,
|
||||
PlaylistSortOrder.PLAYLIST_A_Z
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putString(PLAYLIST_SORT_ORDER, value)
|
||||
}
|
||||
|
||||
val genreSortOrder
|
||||
get() = sharedPreferences.getStringOrDefault(
|
||||
GENRE_SORT_ORDER,
|
||||
GenreSortOrder.GENRE_A_Z
|
||||
)
|
||||
|
||||
val isIgnoreMediaStoreArtwork
|
||||
get() = sharedPreferences.getBoolean(
|
||||
IGNORE_MEDIA_STORE_ARTWORK,
|
||||
false
|
||||
)
|
||||
|
||||
val isVolumeVisibilityMode
|
||||
get() = sharedPreferences.getBoolean(
|
||||
TOGGLE_VOLUME, false
|
||||
)
|
||||
|
||||
var isInitializedBlacklist
|
||||
get() = sharedPreferences.getBoolean(
|
||||
INITIALIZED_BLACKLIST, false
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putBoolean(INITIALIZED_BLACKLIST, value)
|
||||
}
|
||||
|
||||
private val isBlackMode
|
||||
get() = sharedPreferences.getBoolean(
|
||||
BLACK_THEME, false
|
||||
)
|
||||
|
||||
val isExtraControls
|
||||
get() = sharedPreferences.getBoolean(
|
||||
TOGGLE_ADD_CONTROLS, false
|
||||
)
|
||||
|
||||
val isHomeBanner
|
||||
get() = sharedPreferences.getBoolean(
|
||||
TOGGLE_HOME_BANNER, false
|
||||
)
|
||||
var isClassicNotification
|
||||
get() = sharedPreferences.getBoolean(CLASSIC_NOTIFICATION, false)
|
||||
set(value) = sharedPreferences.edit { putBoolean(CLASSIC_NOTIFICATION, value) }
|
||||
|
||||
val isScreenOnEnabled get() = sharedPreferences.getBoolean(KEEP_SCREEN_ON, false)
|
||||
|
||||
val isShuffleModeOn get() = sharedPreferences.getBoolean(TOGGLE_SHUFFLE, false)
|
||||
|
||||
val isSongInfo get() = sharedPreferences.getBoolean(EXTRA_SONG_INFO, false)
|
||||
|
||||
val isPauseOnZeroVolume get() = sharedPreferences.getBoolean(PAUSE_ON_ZERO_VOLUME, false)
|
||||
|
||||
var isSleepTimerFinishMusic
|
||||
get() = sharedPreferences.getBoolean(
|
||||
SLEEP_TIMER_FINISH_SONG, false
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putBoolean(SLEEP_TIMER_FINISH_SONG, value)
|
||||
}
|
||||
|
||||
val isExpandPanel get() = sharedPreferences.getBoolean(EXPAND_NOW_PLAYING_PANEL, false)
|
||||
|
||||
val isHeadsetPlugged
|
||||
get() = sharedPreferences.getBoolean(
|
||||
TOGGLE_HEADSET, false
|
||||
)
|
||||
|
||||
val isAlbumArtOnLockScreen
|
||||
get() = sharedPreferences.getBoolean(
|
||||
ALBUM_ART_ON_LOCK_SCREEN, false
|
||||
)
|
||||
|
||||
val isAudioDucking
|
||||
get() = sharedPreferences.getBoolean(
|
||||
AUDIO_DUCKING, true
|
||||
)
|
||||
|
||||
val isBluetoothSpeaker
|
||||
get() = sharedPreferences.getBoolean(
|
||||
BLUETOOTH_PLAYBACK, false
|
||||
)
|
||||
|
||||
val isBlurredAlbumArt
|
||||
get() = sharedPreferences.getBoolean(
|
||||
BLURRED_ALBUM_ART, false
|
||||
)
|
||||
|
||||
val blurAmount get() = sharedPreferences.getInt(NEW_BLUR_AMOUNT, 25)
|
||||
|
||||
val isCarouselEffect
|
||||
get() = sharedPreferences.getBoolean(
|
||||
CAROUSEL_EFFECT, false
|
||||
)
|
||||
|
||||
var isColoredAppShortcuts
|
||||
get() = sharedPreferences.getBoolean(
|
||||
COLORED_APP_SHORTCUTS, true
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putBoolean(COLORED_APP_SHORTCUTS, value)
|
||||
}
|
||||
|
||||
var isColoredNotification
|
||||
get() = sharedPreferences.getBoolean(
|
||||
COLORED_NOTIFICATION, true
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putBoolean(COLORED_NOTIFICATION, value)
|
||||
}
|
||||
|
||||
var isDesaturatedColor
|
||||
get() = sharedPreferences.getBoolean(
|
||||
DESATURATED_COLOR, false
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putBoolean(DESATURATED_COLOR, value)
|
||||
}
|
||||
|
||||
val isGapLessPlayback
|
||||
get() = sharedPreferences.getBoolean(
|
||||
GAP_LESS_PLAYBACK, false
|
||||
)
|
||||
|
||||
val isAdaptiveColor
|
||||
get() = sharedPreferences.getBoolean(
|
||||
ADAPTIVE_COLOR_APP, false
|
||||
)
|
||||
|
||||
val isFullScreenMode
|
||||
get() = sharedPreferences.getBoolean(
|
||||
TOGGLE_FULL_SCREEN, false
|
||||
)
|
||||
|
||||
val isAudioFocusEnabled
|
||||
get() = sharedPreferences.getBoolean(
|
||||
MANAGE_AUDIO_FOCUS, false
|
||||
)
|
||||
|
||||
val isLockScreen get() = sharedPreferences.getBoolean(LOCK_SCREEN, false)
|
||||
|
||||
var lyricsOption
|
||||
get() = sharedPreferences.getInt(LYRICS_OPTIONS, 1)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putInt(LYRICS_OPTIONS, value)
|
||||
}
|
||||
|
||||
var songGridStyle: GridStyle
|
||||
get() {
|
||||
val id: Int = sharedPreferences.getInt(SONG_GRID_STYLE, 0)
|
||||
// We can directly use "first" kotlin extension function here but
|
||||
// there maybe layout id stored in this so to avoid a crash we use
|
||||
// "firstOrNull"
|
||||
return GridStyle.values().firstOrNull { gridStyle ->
|
||||
gridStyle.id == id
|
||||
} ?: GridStyle.Grid
|
||||
}
|
||||
set(value) = sharedPreferences.edit {
|
||||
putInt(SONG_GRID_STYLE, value.id)
|
||||
}
|
||||
|
||||
var albumGridStyle: GridStyle
|
||||
get() {
|
||||
val id: Int = sharedPreferences.getInt(ALBUM_GRID_STYLE, 0)
|
||||
return GridStyle.values().firstOrNull { gridStyle ->
|
||||
gridStyle.id == id
|
||||
} ?: GridStyle.Grid
|
||||
}
|
||||
set(value) = sharedPreferences.edit {
|
||||
putInt(ALBUM_GRID_STYLE, value.id)
|
||||
}
|
||||
|
||||
var artistGridStyle: GridStyle
|
||||
get() {
|
||||
val id: Int = sharedPreferences.getInt(ARTIST_GRID_STYLE, 3)
|
||||
return GridStyle.values().firstOrNull { gridStyle ->
|
||||
gridStyle.id == id
|
||||
} ?: GridStyle.Circular
|
||||
}
|
||||
set(value) = sharedPreferences.edit {
|
||||
putInt(ARTIST_GRID_STYLE, value.id)
|
||||
}
|
||||
|
||||
val filterLength get() = sharedPreferences.getInt(FILTER_SONG, 20)
|
||||
|
||||
var lastVersion
|
||||
// This was stored as an integer before now it's a long, so avoid a ClassCastException
|
||||
get() = try {
|
||||
sharedPreferences.getLong(LAST_CHANGELOG_VERSION, 0)
|
||||
} catch (e: ClassCastException) {
|
||||
sharedPreferences.edit { remove(LAST_CHANGELOG_VERSION) }
|
||||
0
|
||||
}
|
||||
set(value) = sharedPreferences.edit {
|
||||
putLong(LAST_CHANGELOG_VERSION, value)
|
||||
}
|
||||
|
||||
var lastSleepTimerValue
|
||||
get() = sharedPreferences.getInt(
|
||||
LAST_SLEEP_TIMER_VALUE,
|
||||
30
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putInt(LAST_SLEEP_TIMER_VALUE, value)
|
||||
}
|
||||
|
||||
|
||||
var nextSleepTimerElapsedRealTime
|
||||
get() = sharedPreferences.getInt(
|
||||
NEXT_SLEEP_TIMER_ELAPSED_REALTIME,
|
||||
-1
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putInt(NEXT_SLEEP_TIMER_ELAPSED_REALTIME, value)
|
||||
}
|
||||
|
||||
fun themeResFromPrefValue(themePrefValue: String): Int {
|
||||
return when (themePrefValue) {
|
||||
"light" -> R.style.Theme_RetroMusic_Light
|
||||
"dark" -> R.style.Theme_RetroMusic
|
||||
else -> R.style.Theme_RetroMusic
|
||||
}
|
||||
}
|
||||
|
||||
val homeArtistGridStyle: Int
|
||||
get() {
|
||||
val position = sharedPreferences.getStringOrDefault(
|
||||
HOME_ARTIST_GRID_STYLE, "0"
|
||||
).toInt()
|
||||
val layoutRes =
|
||||
App.getContext().resources.obtainTypedArray(R.array.pref_home_grid_style_layout)
|
||||
.use {
|
||||
it.getResourceId(position, 0)
|
||||
}
|
||||
return if (layoutRes == 0) {
|
||||
R.layout.item_artist
|
||||
} else layoutRes
|
||||
}
|
||||
|
||||
val homeAlbumGridStyle: Int
|
||||
get() {
|
||||
val position = sharedPreferences.getStringOrDefault(
|
||||
HOME_ALBUM_GRID_STYLE, "4"
|
||||
).toInt()
|
||||
val layoutRes = App.getContext()
|
||||
.resources.obtainTypedArray(R.array.pref_home_grid_style_layout).use {
|
||||
it.getResourceId(position, 0)
|
||||
}
|
||||
return if (layoutRes == 0) {
|
||||
R.layout.item_image
|
||||
} else layoutRes
|
||||
}
|
||||
|
||||
val tabTitleMode: Int
|
||||
get() {
|
||||
return when (sharedPreferences.getStringOrDefault(
|
||||
TAB_TEXT_MODE, "1"
|
||||
).toInt()) {
|
||||
1 -> BottomNavigationView.LABEL_VISIBILITY_LABELED
|
||||
0 -> BottomNavigationView.LABEL_VISIBILITY_AUTO
|
||||
2 -> BottomNavigationView.LABEL_VISIBILITY_SELECTED
|
||||
3 -> BottomNavigationView.LABEL_VISIBILITY_UNLABELED
|
||||
else -> BottomNavigationView.LABEL_VISIBILITY_LABELED
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var songGridSize
|
||||
get() = sharedPreferences.getInt(
|
||||
SONG_GRID_SIZE,
|
||||
App.getContext().getIntRes(R.integer.default_list_columns)
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putInt(SONG_GRID_SIZE, value)
|
||||
}
|
||||
|
||||
var songGridSizeLand
|
||||
get() = sharedPreferences.getInt(
|
||||
SONG_GRID_SIZE_LAND,
|
||||
App.getContext().getIntRes(R.integer.default_grid_columns_land)
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putInt(SONG_GRID_SIZE_LAND, value)
|
||||
}
|
||||
|
||||
|
||||
var albumGridSize: Int
|
||||
get() = sharedPreferences.getInt(
|
||||
ALBUM_GRID_SIZE,
|
||||
App.getContext().getIntRes(R.integer.default_grid_columns)
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putInt(ALBUM_GRID_SIZE, value)
|
||||
}
|
||||
|
||||
|
||||
var albumGridSizeLand
|
||||
get() = sharedPreferences.getInt(
|
||||
ALBUM_GRID_SIZE_LAND,
|
||||
App.getContext().getIntRes(R.integer.default_grid_columns_land)
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putInt(ALBUM_GRID_SIZE_LAND, value)
|
||||
}
|
||||
|
||||
|
||||
var artistGridSize
|
||||
get() = sharedPreferences.getInt(
|
||||
ARTIST_GRID_SIZE,
|
||||
App.getContext().getIntRes(R.integer.default_grid_columns)
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putInt(ARTIST_GRID_SIZE, value)
|
||||
}
|
||||
|
||||
|
||||
var artistGridSizeLand
|
||||
get() = sharedPreferences.getInt(
|
||||
ARTIST_GRID_SIZE_LAND,
|
||||
App.getContext().getIntRes(R.integer.default_grid_columns_land)
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putInt(ALBUM_GRID_SIZE_LAND, value)
|
||||
}
|
||||
|
||||
|
||||
var playlistGridSize
|
||||
get() = sharedPreferences.getInt(
|
||||
PLAYLIST_GRID_SIZE,
|
||||
App.getContext().getIntRes(R.integer.default_grid_columns)
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putInt(PLAYLIST_GRID_SIZE, value)
|
||||
}
|
||||
|
||||
|
||||
var playlistGridSizeLand
|
||||
get() = sharedPreferences.getInt(
|
||||
PLAYLIST_GRID_SIZE_LAND,
|
||||
App.getContext().getIntRes(R.integer.default_grid_columns_land)
|
||||
)
|
||||
set(value) = sharedPreferences.edit {
|
||||
putInt(PLAYLIST_GRID_SIZE, value)
|
||||
}
|
||||
|
||||
var albumCoverStyle: AlbumCoverStyle
|
||||
get() {
|
||||
val id: Int = sharedPreferences.getInt(ALBUM_COVER_STYLE, 0)
|
||||
for (albumCoverStyle in AlbumCoverStyle.values()) {
|
||||
if (albumCoverStyle.id == id) {
|
||||
return albumCoverStyle
|
||||
}
|
||||
}
|
||||
return AlbumCoverStyle.Card
|
||||
}
|
||||
set(value) = sharedPreferences.edit { putInt(ALBUM_COVER_STYLE, value.id) }
|
||||
|
||||
|
||||
var nowPlayingScreen: NowPlayingScreen
|
||||
get() {
|
||||
val id: Int = sharedPreferences.getInt(NOW_PLAYING_SCREEN_ID, 0)
|
||||
for (nowPlayingScreen in NowPlayingScreen.values()) {
|
||||
if (nowPlayingScreen.id == id) {
|
||||
return nowPlayingScreen
|
||||
}
|
||||
}
|
||||
return NowPlayingScreen.Adaptive
|
||||
}
|
||||
set(value) = sharedPreferences.edit {
|
||||
putInt(NOW_PLAYING_SCREEN_ID, value.id)
|
||||
// Also set a cover theme for that now playing
|
||||
value.defaultCoverTheme?.let { coverTheme -> albumCoverStyle = coverTheme }
|
||||
}
|
||||
|
||||
val albumCoverTransform: ViewPager.PageTransformer
|
||||
get() {
|
||||
val style = sharedPreferences.getStringOrDefault(
|
||||
ALBUM_COVER_TRANSFORM,
|
||||
"0"
|
||||
).toInt()
|
||||
return when (style) {
|
||||
0 -> NormalPageTransformer()
|
||||
1 -> CascadingPageTransformer()
|
||||
2 -> DepthTransformation()
|
||||
3 -> HorizontalFlipTransformation()
|
||||
4 -> VerticalFlipTransformation()
|
||||
5 -> HingeTransformation()
|
||||
6 -> VerticalStackTransformer()
|
||||
else -> ViewPager.PageTransformer { _, _ -> }
|
||||
}
|
||||
}
|
||||
|
||||
var startDirectory: File
|
||||
get() {
|
||||
val folderPath = FoldersFragment.defaultStartDirectory.path
|
||||
val filePath: String = sharedPreferences.getStringOrDefault(START_DIRECTORY, folderPath)
|
||||
return File(filePath)
|
||||
}
|
||||
set(value) = sharedPreferences.edit {
|
||||
putString(
|
||||
START_DIRECTORY,
|
||||
FileUtil.safeGetCanonicalPath(value)
|
||||
)
|
||||
}
|
||||
|
||||
fun getRecentlyPlayedCutoffTimeMillis(): Long {
|
||||
val calendarUtil = CalendarUtil()
|
||||
val interval: Long = when (sharedPreferences.getString(RECENTLY_PLAYED_CUTOFF, "")) {
|
||||
"today" -> calendarUtil.elapsedToday
|
||||
"this_week" -> calendarUtil.elapsedWeek
|
||||
"past_seven_days" -> calendarUtil.getElapsedDays(7)
|
||||
"past_three_months" -> calendarUtil.getElapsedMonths(3)
|
||||
"this_year" -> calendarUtil.elapsedYear
|
||||
"this_month" -> calendarUtil.elapsedMonth
|
||||
else -> calendarUtil.elapsedMonth
|
||||
}
|
||||
return System.currentTimeMillis() - interval
|
||||
}
|
||||
|
||||
val lastAddedCutoff: Long
|
||||
get() {
|
||||
val calendarUtil = CalendarUtil()
|
||||
val interval =
|
||||
when (sharedPreferences.getStringOrDefault(LAST_ADDED_CUTOFF, "this_month")) {
|
||||
"today" -> calendarUtil.elapsedToday
|
||||
"this_week" -> calendarUtil.elapsedWeek
|
||||
"past_three_months" -> calendarUtil.getElapsedMonths(3)
|
||||
"this_year" -> calendarUtil.elapsedYear
|
||||
"this_month" -> calendarUtil.elapsedMonth
|
||||
else -> calendarUtil.elapsedMonth
|
||||
}
|
||||
return (System.currentTimeMillis() - interval) / 1000
|
||||
}
|
||||
|
||||
val homeSuggestions: Boolean
|
||||
get() = sharedPreferences.getBoolean(
|
||||
TOGGLE_SUGGESTIONS,
|
||||
true
|
||||
)
|
||||
|
||||
val pauseHistory: Boolean
|
||||
get() = sharedPreferences.getBoolean(
|
||||
PAUSE_HISTORY,
|
||||
false
|
||||
)
|
||||
|
||||
var audioFadeDuration
|
||||
get() = sharedPreferences
|
||||
.getInt(AUDIO_FADE_DURATION, 0)
|
||||
set(value) = sharedPreferences.edit { putInt(AUDIO_FADE_DURATION, value) }
|
||||
|
||||
var showLyrics: Boolean
|
||||
get() = sharedPreferences.getBoolean(SHOW_LYRICS, false)
|
||||
set(value) = sharedPreferences.edit { putBoolean(SHOW_LYRICS, value) }
|
||||
|
||||
val rememberLastTab: Boolean
|
||||
get() = sharedPreferences.getBoolean(REMEMBER_LAST_TAB, true)
|
||||
|
||||
var lastTab: Int
|
||||
get() = sharedPreferences
|
||||
.getInt(LAST_USED_TAB, 0)
|
||||
set(value) = sharedPreferences.edit { putInt(LAST_USED_TAB, value) }
|
||||
|
||||
val isWhiteList: Boolean
|
||||
get() = sharedPreferences.getBoolean(WHITELIST_MUSIC, false)
|
||||
|
||||
val crossFadeDuration
|
||||
get() = sharedPreferences
|
||||
.getInt(CROSS_FADE_DURATION, 0)
|
||||
|
||||
val isCrossfadeEnabled get() = crossFadeDuration > 0
|
||||
|
||||
val materialYou
|
||||
get() = sharedPreferences.getBoolean(MATERIAL_YOU, VersionUtils.hasS())
|
||||
|
||||
val isCustomFont
|
||||
get() = sharedPreferences.getBoolean(CUSTOM_FONT, false)
|
||||
|
||||
val isSnowFalling
|
||||
get() = sharedPreferences.getBoolean(SNOWFALL, false)
|
||||
|
||||
val lyricsType: LyricsType
|
||||
get() = if (sharedPreferences.getString(LYRICS_TYPE, "0") == "0") {
|
||||
LyricsType.REPLACE_COVER
|
||||
} else {
|
||||
LyricsType.OVER_COVER
|
||||
}
|
||||
|
||||
var playbackSpeed
|
||||
get() = sharedPreferences
|
||||
.getFloat(PLAYBACK_SPEED, 1F)
|
||||
set(value) = sharedPreferences.edit { putFloat(PLAYBACK_SPEED, value) }
|
||||
|
||||
var playbackPitch
|
||||
get() = sharedPreferences
|
||||
.getFloat(PLAYBACK_PITCH, 1F)
|
||||
set(value) = sharedPreferences.edit { putFloat(PLAYBACK_PITCH, value) }
|
||||
|
||||
val appBarMode: TopAppBarLayout.AppBarMode
|
||||
get() = if (sharedPreferences.getString(APPBAR_MODE, "1") == "0") {
|
||||
TopAppBarLayout.AppBarMode.COLLAPSING
|
||||
} else {
|
||||
TopAppBarLayout.AppBarMode.SIMPLE
|
||||
}
|
||||
|
||||
val wallpaperAccent
|
||||
get() = sharedPreferences.getBoolean(
|
||||
WALLPAPER_ACCENT,
|
||||
VersionUtils.hasOreoMR1() && !VersionUtils.hasS()
|
||||
)
|
||||
|
||||
val lyricsScreenOn
|
||||
get() = sharedPreferences.getBoolean(SCREEN_ON_LYRICS, false)
|
||||
|
||||
val circlePlayButton
|
||||
get() = sharedPreferences.getBoolean(CIRCLE_PLAY_BUTTON, false)
|
||||
|
||||
val swipeAnywhereToChangeSong
|
||||
get() = sharedPreferences.getBoolean(SWIPE_ANYWHERE_NOW_PLAYING, true)
|
||||
|
||||
val swipeDownToDismiss
|
||||
get() = sharedPreferences.getBoolean(SWIPE_DOWN_DISMISS, true)
|
||||
}
|
||||
|
||||
enum class LyricsType {
|
||||
REPLACE_COVER, OVER_COVER
|
||||
}
|
|
@ -0,0 +1,249 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
|
||||
package code.name.monkey.retromusic.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Color;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.palette.graphics.Palette;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import code.name.monkey.appthemehelper.ThemeStore;
|
||||
import code.name.monkey.appthemehelper.util.ColorUtil;
|
||||
import code.name.monkey.appthemehelper.util.VersionUtils;
|
||||
import io.github.muntashirakon.music.R;
|
||||
|
||||
public class RetroColorUtil {
|
||||
public static int desaturateColor(int color, float ratio) {
|
||||
float[] hsv = new float[3];
|
||||
Color.colorToHSV(color, hsv);
|
||||
|
||||
hsv[1] = (hsv[1] * ratio) + (0.2f * (1.0f - ratio));
|
||||
|
||||
return Color.HSVToColor(hsv);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static Palette generatePalette(@Nullable Bitmap bitmap) {
|
||||
return bitmap == null ? null : Palette.from(bitmap).clearFilters().generate();
|
||||
}
|
||||
|
||||
public static int getTextColor(@Nullable Palette palette) {
|
||||
if (palette == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int inverse = -1;
|
||||
if (palette.getVibrantSwatch() != null) {
|
||||
inverse = palette.getVibrantSwatch().getRgb();
|
||||
} else if (palette.getLightVibrantSwatch() != null) {
|
||||
inverse = palette.getLightVibrantSwatch().getRgb();
|
||||
} else if (palette.getDarkVibrantSwatch() != null) {
|
||||
inverse = palette.getDarkVibrantSwatch().getRgb();
|
||||
}
|
||||
|
||||
int background = getSwatch(palette).getRgb();
|
||||
|
||||
if (inverse != -1) {
|
||||
return ColorUtil.INSTANCE.getReadableText(inverse, background, 150);
|
||||
}
|
||||
return ColorUtil.INSTANCE.stripAlpha(getSwatch(palette).getTitleTextColor());
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static Palette.Swatch getSwatch(@Nullable Palette palette) {
|
||||
if (palette == null) {
|
||||
return new Palette.Swatch(Color.WHITE, 1);
|
||||
}
|
||||
return getBestPaletteSwatchFrom(palette.getSwatches());
|
||||
}
|
||||
|
||||
public static int getMatColor(Context context, String typeColor) {
|
||||
int returnColor = Color.BLACK;
|
||||
int arrayId =
|
||||
context
|
||||
.getResources()
|
||||
.getIdentifier(
|
||||
"md_" + typeColor, "array", context.getApplicationContext().getPackageName());
|
||||
|
||||
if (arrayId != 0) {
|
||||
TypedArray colors = context.getResources().obtainTypedArray(arrayId);
|
||||
int index = (int) (Math.random() * colors.length());
|
||||
returnColor = colors.getColor(index, Color.BLACK);
|
||||
colors.recycle();
|
||||
}
|
||||
return returnColor;
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
public static int getColor(@Nullable Palette palette, int fallback) {
|
||||
if (palette != null) {
|
||||
if (palette.getVibrantSwatch() != null) {
|
||||
return palette.getVibrantSwatch().getRgb();
|
||||
} else if (palette.getDarkVibrantSwatch() != null) {
|
||||
return palette.getDarkVibrantSwatch().getRgb();
|
||||
} else if (palette.getLightVibrantSwatch() != null) {
|
||||
return palette.getLightVibrantSwatch().getRgb();
|
||||
} else if (palette.getMutedSwatch() != null) {
|
||||
return palette.getMutedSwatch().getRgb();
|
||||
} else if (palette.getLightMutedSwatch() != null) {
|
||||
return palette.getLightMutedSwatch().getRgb();
|
||||
} else if (palette.getDarkMutedSwatch() != null) {
|
||||
return palette.getDarkMutedSwatch().getRgb();
|
||||
} else if (!palette.getSwatches().isEmpty()) {
|
||||
return Collections.max(palette.getSwatches(), SwatchComparator.getInstance()).getRgb();
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static Palette.Swatch getTextSwatch(@Nullable Palette palette) {
|
||||
if (palette == null) {
|
||||
return new Palette.Swatch(Color.BLACK, 1);
|
||||
}
|
||||
if (palette.getVibrantSwatch() != null) {
|
||||
return palette.getVibrantSwatch();
|
||||
} else {
|
||||
return new Palette.Swatch(Color.BLACK, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
public static int getBackgroundColor(@Nullable Palette palette) {
|
||||
return getProperBackgroundSwatch(palette).getRgb();
|
||||
}
|
||||
|
||||
private static Palette.Swatch getProperBackgroundSwatch(@Nullable Palette palette) {
|
||||
if (palette == null) {
|
||||
return new Palette.Swatch(Color.BLACK, 1);
|
||||
}
|
||||
if (palette.getDarkMutedSwatch() != null) {
|
||||
return palette.getDarkMutedSwatch();
|
||||
} else if (palette.getMutedSwatch() != null) {
|
||||
return palette.getMutedSwatch();
|
||||
} else if (palette.getLightMutedSwatch() != null) {
|
||||
return palette.getLightMutedSwatch();
|
||||
} else {
|
||||
return new Palette.Swatch(Color.BLACK, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static Palette.Swatch getBestPaletteSwatchFrom(Palette palette) {
|
||||
if (palette != null) {
|
||||
if (palette.getVibrantSwatch() != null) {
|
||||
return palette.getVibrantSwatch();
|
||||
} else if (palette.getMutedSwatch() != null) {
|
||||
return palette.getMutedSwatch();
|
||||
} else if (palette.getDarkVibrantSwatch() != null) {
|
||||
return palette.getDarkVibrantSwatch();
|
||||
} else if (palette.getDarkMutedSwatch() != null) {
|
||||
return palette.getDarkMutedSwatch();
|
||||
} else if (palette.getLightVibrantSwatch() != null) {
|
||||
return palette.getLightVibrantSwatch();
|
||||
} else if (palette.getLightMutedSwatch() != null) {
|
||||
return palette.getLightMutedSwatch();
|
||||
} else if (!palette.getSwatches().isEmpty()) {
|
||||
return getBestPaletteSwatchFrom(palette.getSwatches());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Palette.Swatch getBestPaletteSwatchFrom(List<Palette.Swatch> swatches) {
|
||||
if (swatches == null) {
|
||||
return null;
|
||||
}
|
||||
return Collections.max(
|
||||
swatches,
|
||||
(opt1, opt2) -> {
|
||||
int a = opt1 == null ? 0 : opt1.getPopulation();
|
||||
int b = opt2 == null ? 0 : opt2.getPopulation();
|
||||
return a - b;
|
||||
});
|
||||
}
|
||||
|
||||
public static int getDominantColor(Bitmap bitmap, int defaultFooterColor) {
|
||||
List<Palette.Swatch> swatchesTemp = Palette.from(bitmap).generate().getSwatches();
|
||||
List<Palette.Swatch> swatches = new ArrayList<>(swatchesTemp);
|
||||
Collections.sort(
|
||||
swatches, (swatch1, swatch2) -> swatch2.getPopulation() - swatch1.getPopulation());
|
||||
return swatches.size() > 0 ? swatches.get(0).getRgb() : defaultFooterColor;
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
public static int shiftBackgroundColorForLightText(@ColorInt int backgroundColor) {
|
||||
while (ColorUtil.INSTANCE.isColorLight(backgroundColor)) {
|
||||
backgroundColor = ColorUtil.INSTANCE.darkenColor(backgroundColor);
|
||||
}
|
||||
return backgroundColor;
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
public static int shiftBackgroundColorForDarkText(@ColorInt int backgroundColor) {
|
||||
int color = backgroundColor;
|
||||
while (!ColorUtil.INSTANCE.isColorLight(backgroundColor)) {
|
||||
color = ColorUtil.INSTANCE.lightenColor(backgroundColor);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
public static int shiftBackgroundColor(@ColorInt int backgroundColor) {
|
||||
int color = backgroundColor;
|
||||
if (ColorUtil.INSTANCE.isColorLight(color)) {
|
||||
color = ColorUtil.INSTANCE.shiftColor(color, 0.5F);
|
||||
} else {
|
||||
color = ColorUtil.INSTANCE.shiftColor(color, 1.5F);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
public static int getMD3AccentColor(@NotNull Context context) {
|
||||
if (VersionUtils.hasS()) {
|
||||
return ContextCompat.getColor(context, R.color.m3_accent_color);
|
||||
} else {
|
||||
return ThemeStore.Companion.accentColor(context);
|
||||
}
|
||||
}
|
||||
|
||||
private static class SwatchComparator implements Comparator<Palette.Swatch> {
|
||||
|
||||
private static SwatchComparator sInstance;
|
||||
|
||||
static SwatchComparator getInstance() {
|
||||
if (sInstance == null) {
|
||||
sInstance = new SwatchComparator();
|
||||
}
|
||||
return sInstance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compare(Palette.Swatch lhs, Palette.Swatch rhs) {
|
||||
return lhs.getPopulation() - rhs.getPopulation();
|
||||
}
|
||||
}
|
||||
}
|
117
app/src/main/java/code/name/monkey/retromusic/util/RetroUtil.kt
Normal file
117
app/src/main/java/code/name/monkey/retromusic/util/RetroUtil.kt
Normal file
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Point
|
||||
import code.name.monkey.retromusic.App.Companion.getContext
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.text.DecimalFormat
|
||||
import java.util.*
|
||||
|
||||
object RetroUtil {
|
||||
fun formatValue(numValue: Float): String {
|
||||
var value = numValue
|
||||
val arr = arrayOf("", "K", "M", "B", "T", "P", "E")
|
||||
var index = 0
|
||||
while (value / 1000 >= 1) {
|
||||
value /= 1000
|
||||
index++
|
||||
}
|
||||
val decimalFormat = DecimalFormat("#.##")
|
||||
return String.format("%s %s", decimalFormat.format(value.toDouble()), arr[index])
|
||||
}
|
||||
|
||||
fun frequencyCount(frequency: Int): Float {
|
||||
return (frequency / 1000.0).toFloat()
|
||||
}
|
||||
|
||||
fun getScreenSize(context: Context): Point {
|
||||
val x: Int = context.resources.displayMetrics.widthPixels
|
||||
val y: Int = context.resources.displayMetrics.heightPixels
|
||||
return Point(x, y)
|
||||
}
|
||||
|
||||
val statusBarHeight: Int
|
||||
get() {
|
||||
var result = 0
|
||||
val resourceId = getContext()
|
||||
.resources
|
||||
.getIdentifier("status_bar_height", "dimen", "android")
|
||||
if (resourceId > 0) {
|
||||
result = getContext().resources.getDimensionPixelSize(resourceId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
val navigationBarHeight: Int
|
||||
get() {
|
||||
var result = 0
|
||||
val resourceId = getContext()
|
||||
.resources
|
||||
.getIdentifier("navigation_bar_height", "dimen", "android")
|
||||
if (resourceId > 0) {
|
||||
result = getContext().resources.getDimensionPixelSize(resourceId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
val isLandscape: Boolean
|
||||
get() = (getContext().resources.configuration.orientation
|
||||
== Configuration.ORIENTATION_LANDSCAPE)
|
||||
val isTablet: Boolean
|
||||
get() = (getContext().resources.configuration.smallestScreenWidthDp
|
||||
>= 600)
|
||||
|
||||
fun getIpAddress(useIPv4: Boolean): String? {
|
||||
try {
|
||||
val interfaces: List<NetworkInterface> =
|
||||
Collections.list(NetworkInterface.getNetworkInterfaces())
|
||||
for (intf in interfaces) {
|
||||
val addrs: List<InetAddress> = Collections.list(intf.inetAddresses)
|
||||
for (addr in addrs) {
|
||||
if (!addr.isLoopbackAddress) {
|
||||
val sAddr = addr.hostAddress
|
||||
|
||||
if (sAddr != null) {
|
||||
val isIPv4 = sAddr.indexOf(':') < 0
|
||||
if (useIPv4) {
|
||||
if (isIPv4) return sAddr
|
||||
} else {
|
||||
if (!isIPv4) {
|
||||
val delim = sAddr.indexOf('%') // drop ip6 zone suffix
|
||||
return if (delim < 0) {
|
||||
sAddr.uppercase()
|
||||
} else {
|
||||
sAddr.substring(
|
||||
0,
|
||||
delim
|
||||
).uppercase()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
|
||||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.BaseColumns
|
||||
import android.provider.MediaStore
|
||||
import android.provider.Settings
|
||||
import androidx.core.net.toUri
|
||||
import code.name.monkey.appthemehelper.util.VersionUtils
|
||||
import code.name.monkey.retromusic.R
|
||||
import code.name.monkey.retromusic.extensions.showToast
|
||||
import code.name.monkey.retromusic.model.Song
|
||||
import code.name.monkey.retromusic.util.MusicUtil.getSongFileUri
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
object RingtoneManager {
|
||||
fun setRingtone(context: Context, song: Song) {
|
||||
val uri = getSongFileUri(song.id)
|
||||
val resolver = context.contentResolver
|
||||
|
||||
try {
|
||||
val cursor = resolver.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
arrayOf(MediaStore.MediaColumns.TITLE),
|
||||
BaseColumns._ID + "=?",
|
||||
arrayOf(song.id.toString()), null
|
||||
)
|
||||
cursor.use { cursorSong ->
|
||||
if (cursorSong != null && cursorSong.count == 1) {
|
||||
cursorSong.moveToFirst()
|
||||
Settings.System.putString(resolver, Settings.System.RINGTONE, uri.toString())
|
||||
val message = context
|
||||
.getString(R.string.x_has_been_set_as_ringtone, cursorSong.getString(0))
|
||||
context.showToast(message)
|
||||
}
|
||||
}
|
||||
} catch (ignored: SecurityException) {
|
||||
}
|
||||
}
|
||||
|
||||
fun requiresDialog(context: Context): Boolean {
|
||||
if (VersionUtils.hasMarshmallow()) {
|
||||
if (!Settings.System.canWrite(context)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun showDialog(context: Context) {
|
||||
return MaterialAlertDialogBuilder(context, R.style.MaterialAlertDialogTheme)
|
||||
.setTitle(R.string.dialog_title_set_ringtone)
|
||||
.setMessage(R.string.dialog_message_set_ringtone)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
val intent = Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS)
|
||||
intent.data = ("package:" + context.applicationContext.packageName).toUri()
|
||||
context.startActivity(intent)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create().show()
|
||||
}
|
||||
}
|
319
app/src/main/java/code/name/monkey/retromusic/util/SAFUtil.java
Normal file
319
app/src/main/java/code/name/monkey/retromusic/util/SAFUtil.java
Normal file
|
@ -0,0 +1,319 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
|
||||
package code.name.monkey.retromusic.util;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.UriPermission;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.provider.DocumentsContract;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import org.jaudiotagger.audio.AudioFile;
|
||||
import org.jaudiotagger.audio.exceptions.CannotWriteException;
|
||||
import org.jaudiotagger.audio.generic.Utils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import io.github.muntashirakon.music.R;
|
||||
import io.github.muntashirakon.music.activities.saf.SAFRequestActivity;
|
||||
import code.name.monkey.retromusic.model.Song;
|
||||
|
||||
public class SAFUtil {
|
||||
|
||||
public static final String TAG = SAFUtil.class.getSimpleName();
|
||||
public static final String SEPARATOR = "###/SAF/###";
|
||||
|
||||
public static final int REQUEST_SAF_PICK_FILE = 42;
|
||||
public static final int REQUEST_SAF_PICK_TREE = 43;
|
||||
|
||||
public static boolean isSAFRequired(File file) {
|
||||
return !file.canWrite();
|
||||
}
|
||||
|
||||
public static boolean isSAFRequired(String path) {
|
||||
return isSAFRequired(new File(path));
|
||||
}
|
||||
|
||||
public static boolean isSAFRequired(AudioFile audio) {
|
||||
return isSAFRequired(audio.getFile());
|
||||
}
|
||||
|
||||
public static boolean isSAFRequired(Song song) {
|
||||
return isSAFRequired(song.getData());
|
||||
}
|
||||
|
||||
public static boolean isSAFRequired(List<String> paths) {
|
||||
for (String path : paths) {
|
||||
if (isSAFRequired(path)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean isSAFRequiredForSongs(List<Song> songs) {
|
||||
for (Song song : songs) {
|
||||
if (isSAFRequired(song)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.KITKAT)
|
||||
public static void openFilePicker(Activity activity) {
|
||||
Intent i = new Intent(Intent.ACTION_CREATE_DOCUMENT);
|
||||
i.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
i.setType("audio/*");
|
||||
i.putExtra("android.content.extra.SHOW_ADVANCED", true);
|
||||
activity.startActivityForResult(i, SAFUtil.REQUEST_SAF_PICK_FILE);
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.KITKAT)
|
||||
public static void openFilePicker(Fragment fragment) {
|
||||
Intent i = new Intent(Intent.ACTION_CREATE_DOCUMENT);
|
||||
i.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
i.setType("audio/*");
|
||||
i.putExtra("android.content.extra.SHOW_ADVANCED", true);
|
||||
fragment.startActivityForResult(i, SAFUtil.REQUEST_SAF_PICK_FILE);
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
public static void openTreePicker(Activity activity) {
|
||||
Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
i.putExtra("android.content.extra.SHOW_ADVANCED", true);
|
||||
activity.startActivityForResult(i, SAFUtil.REQUEST_SAF_PICK_TREE);
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
public static void openTreePicker(Fragment fragment) {
|
||||
Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
i.putExtra("android.content.extra.SHOW_ADVANCED", true);
|
||||
fragment.startActivityForResult(i, SAFUtil.REQUEST_SAF_PICK_TREE);
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.KITKAT)
|
||||
public static void saveTreeUri(Context context, Intent data) {
|
||||
Uri uri = data.getData();
|
||||
if (uri != null) {
|
||||
context.getContentResolver().takePersistableUriPermission(
|
||||
uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
PreferenceUtil.INSTANCE.setSafSdCardUri(uri.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
public static boolean isTreeUriSaved() {
|
||||
return !TextUtils.isEmpty(PreferenceUtil.INSTANCE.getSafSdCardUri());
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
public static boolean isSDCardAccessGranted(Context context) {
|
||||
if (!isTreeUriSaved()) return false;
|
||||
|
||||
String sdcardUri = PreferenceUtil.INSTANCE.getSafSdCardUri();
|
||||
|
||||
List<UriPermission> perms = context.getContentResolver().getPersistedUriPermissions();
|
||||
for (UriPermission perm : perms) {
|
||||
if (perm.getUri().toString().equals(sdcardUri) && perm.isWritePermission()) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* https://github.com/vanilla-music/vanilla-music-tag-editor/commit/e00e87fef289f463b6682674aa54be834179ccf0#diff-d436417358d5dfbb06846746d43c47a5R359
|
||||
* Finds needed file through Document API for SAF. It's not optimized yet - you can still gain
|
||||
* wrong URI on files such as "/a/b/c.mp3" and "/b/a/c.mp3", but I consider it complete enough to
|
||||
* be usable.
|
||||
*
|
||||
* @param dir - document file representing current dir of search
|
||||
* @param segments - path segments that are left to find
|
||||
* @return URI for found file. Null if nothing found.
|
||||
*/
|
||||
@Nullable
|
||||
public static Uri findDocument(DocumentFile dir, List<String> segments) {
|
||||
if (dir == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (DocumentFile file : dir.listFiles()) {
|
||||
int index = segments.indexOf(file.getName());
|
||||
if (index == -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.isDirectory()) {
|
||||
segments.remove(file.getName());
|
||||
return findDocument(file, segments);
|
||||
}
|
||||
|
||||
if (file.isFile() && index == segments.size() - 1) {
|
||||
// got to the last part
|
||||
return file.getUri();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void write(Context context, AudioFile audio, Uri safUri) {
|
||||
if (isSAFRequired(audio)) {
|
||||
writeSAF(context, audio, safUri);
|
||||
} else {
|
||||
try {
|
||||
writeFile(audio);
|
||||
} catch (CannotWriteException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void writeFile(AudioFile audio) throws CannotWriteException {
|
||||
audio.commit();
|
||||
}
|
||||
|
||||
public static void writeSAF(Context context, AudioFile audio, Uri safUri) {
|
||||
Uri uri = null;
|
||||
|
||||
if (context == null) {
|
||||
Log.e(TAG, "writeSAF: context == null");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTreeUriSaved()) {
|
||||
List<String> pathSegments =
|
||||
new ArrayList<>(Arrays.asList(audio.getFile().getAbsolutePath().split("/")));
|
||||
Uri sdcard = Uri.parse(PreferenceUtil.INSTANCE.getSafSdCardUri());
|
||||
uri = findDocument(DocumentFile.fromTreeUri(context, sdcard), pathSegments);
|
||||
}
|
||||
|
||||
if (uri == null) {
|
||||
uri = safUri;
|
||||
}
|
||||
|
||||
if (uri == null) {
|
||||
Log.e(TAG, "writeSAF: Can't get SAF URI");
|
||||
toast(context, context.getString(R.string.saf_error_uri));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// copy file to app folder to use jaudiotagger
|
||||
final File original = audio.getFile();
|
||||
File temp = File.createTempFile("tmp-media", '.' + Utils.getExtension(original));
|
||||
Utils.copy(original, temp);
|
||||
temp.deleteOnExit();
|
||||
audio.setFile(temp);
|
||||
writeFile(audio);
|
||||
|
||||
ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "rw");
|
||||
if (pfd == null) {
|
||||
Log.e(TAG, "writeSAF: SAF provided incorrect URI: " + uri);
|
||||
return;
|
||||
}
|
||||
|
||||
// now read persisted data and write it to real FD provided by SAF
|
||||
FileInputStream fis = new FileInputStream(temp);
|
||||
byte[] audioContent = FileUtil.readBytes(fis);
|
||||
FileOutputStream fos = new FileOutputStream(pfd.getFileDescriptor());
|
||||
fos.write(audioContent);
|
||||
fos.close();
|
||||
|
||||
temp.delete();
|
||||
} catch (final Exception e) {
|
||||
Log.e(TAG, "writeSAF: Failed to write to file descriptor provided by SAF", e);
|
||||
|
||||
toast(
|
||||
context,
|
||||
String.format(context.getString(R.string.saf_write_failed), e.getLocalizedMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean delete(Context context, String path, Uri safUri) {
|
||||
if (isSAFRequired(path)) {
|
||||
return deleteUsingSAF(context, path, safUri);
|
||||
} else {
|
||||
try {
|
||||
deleteFile(path);
|
||||
} catch (NullPointerException e) {
|
||||
Log.e("MusicUtils", "Failed to find file " + path);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void deleteFile(String path) {
|
||||
new File(path).delete();
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.KITKAT)
|
||||
private static boolean deleteUsingSAF(Context context, String path, Uri safUri) {
|
||||
if (context == null) {
|
||||
Log.e(TAG, "deleteSAF: context == null");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (safUri == null && isTreeUriSaved()) {
|
||||
List<String> pathSegments = new ArrayList<>(Arrays.asList(path.split("/")));
|
||||
Uri sdcard = Uri.parse(PreferenceUtil.INSTANCE.getSafSdCardUri());
|
||||
safUri = findDocument(DocumentFile.fromTreeUri(context, sdcard), pathSegments);
|
||||
}
|
||||
|
||||
if (safUri == null) {
|
||||
requestSAF(context);
|
||||
toast(context, context.getString(R.string.saf_error_uri));
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
DocumentsContract.deleteDocument(context.getContentResolver(), safUri);
|
||||
} catch (final Exception e) {
|
||||
Log.e(TAG, "deleteSAF: Failed to delete a file descriptor provided by SAF", e);
|
||||
|
||||
toast(
|
||||
context,
|
||||
String.format(context.getString(R.string.saf_delete_failed), e.getLocalizedMessage()));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void requestSAF(Context context) {
|
||||
Intent intent = new Intent(context, SAFRequestActivity.class);
|
||||
context.startActivity(intent);
|
||||
}
|
||||
|
||||
private static void toast(final Context context, final String message) {
|
||||
if (context instanceof Activity) {
|
||||
((Activity) context)
|
||||
.runOnUiThread(() -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show());
|
||||
}
|
||||
}
|
||||
}
|
46
app/src/main/java/code/name/monkey/retromusic/util/Share.kt
Normal file
46
app/src/main/java/code/name/monkey/retromusic/util/Share.kt
Normal file
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright (c) 2020 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
|
||||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Created by hemanths on 2020-02-02.
|
||||
*/
|
||||
|
||||
object Share {
|
||||
fun shareStoryToSocial(context: Context, uri: Uri) {
|
||||
val feedIntent = Intent(Intent.ACTION_SEND)
|
||||
feedIntent.type = "image/*"
|
||||
feedIntent.putExtra(Intent.EXTRA_STREAM, uri)
|
||||
context.startActivity(feedIntent, null)
|
||||
}
|
||||
|
||||
fun shareFile(context: Context, file: File) {
|
||||
val attachmentUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
context.applicationContext.packageName,
|
||||
file
|
||||
)
|
||||
val sharingIntent = Intent(Intent.ACTION_SEND)
|
||||
sharingIntent.type = "text/*"
|
||||
sharingIntent.putExtra(Intent.EXTRA_STREAM, attachmentUri)
|
||||
context.startActivity(Intent.createChooser(sharingIntent, "send bug report"))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
|
||||
package code.name.monkey.retromusic.util;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
public class SwipeAndDragHelper extends ItemTouchHelper.Callback {
|
||||
|
||||
private final ActionCompletionContract contract;
|
||||
|
||||
public SwipeAndDragHelper(@NonNull ActionCompletionContract contract) {
|
||||
this.contract = contract;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMovementFlags(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
|
||||
return makeMovementFlags(dragFlags, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMove(
|
||||
@NonNull RecyclerView recyclerView,
|
||||
RecyclerView.ViewHolder viewHolder,
|
||||
RecyclerView.ViewHolder target) {
|
||||
contract.onViewMoved(viewHolder.getLayoutPosition(), target.getLayoutPosition());
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {}
|
||||
|
||||
@Override
|
||||
public boolean isLongPressDragEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChildDraw(
|
||||
@NonNull Canvas c,
|
||||
@NonNull RecyclerView recyclerView,
|
||||
@NonNull RecyclerView.ViewHolder viewHolder,
|
||||
float dX,
|
||||
float dY,
|
||||
int actionState,
|
||||
boolean isCurrentlyActive) {
|
||||
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
|
||||
float alpha = 1 - (Math.abs(dX) / recyclerView.getWidth());
|
||||
viewHolder.itemView.setAlpha(alpha);
|
||||
}
|
||||
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
|
||||
}
|
||||
|
||||
public interface ActionCompletionContract {
|
||||
void onViewMoved(int oldPosition, int newPosition);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
|
||||
package code.name.monkey.retromusic.util;
|
||||
|
||||
/** @author Hemanth S (h4h13). */
|
||||
public class TempUtils {
|
||||
|
||||
// Enums
|
||||
public static final int TEMPO_STROLL = 0;
|
||||
public static final int TEMPO_WALK = 1;
|
||||
public static final int TEMPO_LIGHT_JOG = 2;
|
||||
public static final int TEMPO_JOG = 3;
|
||||
public static final int TEMPO_RUN = 4;
|
||||
public static final int TEMPO_SPRINT = 5;
|
||||
public static final int TEMPO_UNKNOWN = 6;
|
||||
|
||||
// take BPM as an int
|
||||
public static int getTempoFromBPM(int bpm) {
|
||||
|
||||
// STROLL less than 60
|
||||
if (bpm < 60) {
|
||||
return TEMPO_STROLL;
|
||||
}
|
||||
|
||||
// WALK between 60 and 70, or between 120 and 140
|
||||
else if (bpm < 70 || bpm >= 120 && bpm < 140) {
|
||||
return TEMPO_WALK;
|
||||
}
|
||||
|
||||
// LIGHT_JOG between 70 and 80, or between 140 and 160
|
||||
else if (bpm < 80 || bpm >= 140 && bpm < 160) {
|
||||
return TEMPO_LIGHT_JOG;
|
||||
}
|
||||
|
||||
// JOG between 80 and 90, or between 160 and 180
|
||||
else if (bpm < 90 || bpm >= 160 && bpm < 180) {
|
||||
return TEMPO_JOG;
|
||||
}
|
||||
|
||||
// RUN between 90 and 100, or between 180 and 200
|
||||
else if (bpm < 100 || bpm >= 180 && bpm < 200) {
|
||||
return TEMPO_RUN;
|
||||
}
|
||||
|
||||
// SPRINT between 100 and 120
|
||||
else if (bpm < 120) {
|
||||
return TEMPO_SPRINT;
|
||||
}
|
||||
|
||||
// UNKNOWN
|
||||
else {
|
||||
return TEMPO_UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
// take BPM as a string
|
||||
public static int getTempoFromBPM(String bpm) {
|
||||
// cast to an int from string
|
||||
try {
|
||||
// convert the string to an int
|
||||
return getTempoFromBPM(Integer.parseInt(bpm.trim()));
|
||||
} catch (NumberFormatException nfe) {
|
||||
|
||||
//
|
||||
return TEMPO_UNKNOWN;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright (c) 2020 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.view.ViewGroup
|
||||
import code.name.monkey.appthemehelper.ThemeStore.Companion.accentColor
|
||||
import code.name.monkey.appthemehelper.util.ColorUtil.isColorLight
|
||||
import code.name.monkey.appthemehelper.util.MaterialValueHelper.getPrimaryTextColor
|
||||
import code.name.monkey.appthemehelper.util.TintHelper
|
||||
import code.name.monkey.retromusic.views.PopupBackground
|
||||
import me.zhanghai.android.fastscroll.FastScroller
|
||||
import me.zhanghai.android.fastscroll.FastScrollerBuilder
|
||||
import me.zhanghai.android.fastscroll.PopupStyles
|
||||
import me.zhanghai.android.fastscroll.R
|
||||
|
||||
object ThemedFastScroller {
|
||||
fun create(view: ViewGroup): FastScroller {
|
||||
val context = view.context
|
||||
val color = accentColor(context)
|
||||
val textColor = getPrimaryTextColor(context, isColorLight(color))
|
||||
val fastScrollerBuilder = FastScrollerBuilder(view)
|
||||
fastScrollerBuilder.useMd2Style()
|
||||
fastScrollerBuilder.setPopupStyle { popupText ->
|
||||
PopupStyles.MD2.accept(popupText)
|
||||
popupText.background = PopupBackground(context, color)
|
||||
popupText.setTextColor(textColor)
|
||||
}
|
||||
|
||||
fastScrollerBuilder.setThumbDrawable(
|
||||
TintHelper.createTintedDrawable(
|
||||
context,
|
||||
R.drawable.afs_md2_thumb,
|
||||
color
|
||||
)
|
||||
)
|
||||
return fastScrollerBuilder.build()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import code.name.monkey.retromusic.Constants
|
||||
|
||||
object UriUtil {
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun getUriFromPath(context: Context, path: String): Uri {
|
||||
val uri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
val proj = arrayOf(MediaStore.Files.FileColumns._ID)
|
||||
context.contentResolver.query(
|
||||
uri, proj, Constants.DATA + "=?", arrayOf(path), null
|
||||
)?.use { cursor ->
|
||||
if (cursor.count != 0) {
|
||||
cursor.moveToFirst()
|
||||
return ContentUris.withAppendedId(uri, cursor.getLong(0))
|
||||
}
|
||||
}
|
||||
return Uri.EMPTY
|
||||
}
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright (c) 2019 Hemanth Savarala.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3
|
||||
*
|
||||
* This is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
|
||||
*
|
||||
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
* See the GNU General Public License for more details.
|
||||
*/
|
||||
|
||||
package code.name.monkey.retromusic.util
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.content.res.Resources
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.SeekBar
|
||||
import androidx.core.graphics.BlendModeColorFilterCompat
|
||||
import androidx.core.graphics.BlendModeCompat.SRC_IN
|
||||
import code.name.monkey.appthemehelper.util.ATHUtil
|
||||
import code.name.monkey.appthemehelper.util.ColorUtil
|
||||
import code.name.monkey.appthemehelper.util.MaterialValueHelper
|
||||
|
||||
object ViewUtil {
|
||||
|
||||
const val RETRO_MUSIC_ANIM_TIME = 1000
|
||||
|
||||
fun setProgressDrawable(progressSlider: SeekBar, newColor: Int, thumbTint: Boolean = false) {
|
||||
|
||||
if (thumbTint) {
|
||||
progressSlider.thumbTintList = ColorStateList.valueOf(newColor)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
val layerDrawable = progressSlider.progressDrawable as LayerDrawable
|
||||
val progressDrawable = layerDrawable.findDrawableByLayerId(android.R.id.progress)
|
||||
progressDrawable.colorFilter =
|
||||
BlendModeColorFilterCompat.createBlendModeColorFilterCompat(newColor, SRC_IN)
|
||||
} else {
|
||||
progressSlider.progressTintList = ColorStateList.valueOf(newColor)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun setProgressDrawable(progressSlider: ProgressBar, newColor: Int) {
|
||||
|
||||
val layerDrawable = progressSlider.progressDrawable as LayerDrawable
|
||||
|
||||
val progress = layerDrawable.findDrawableByLayerId(android.R.id.progress)
|
||||
progress.colorFilter =
|
||||
BlendModeColorFilterCompat.createBlendModeColorFilterCompat(newColor, SRC_IN)
|
||||
|
||||
val background = layerDrawable.findDrawableByLayerId(android.R.id.background)
|
||||
val primaryColor =
|
||||
ATHUtil.resolveColor(progressSlider.context, android.R.attr.windowBackground)
|
||||
background.colorFilter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(
|
||||
MaterialValueHelper.getPrimaryDisabledTextColor(
|
||||
progressSlider.context,
|
||||
ColorUtil.isColorLight(primaryColor)
|
||||
), SRC_IN
|
||||
)
|
||||
|
||||
val secondaryProgress = layerDrawable.findDrawableByLayerId(android.R.id.secondaryProgress)
|
||||
secondaryProgress?.colorFilter =
|
||||
BlendModeColorFilterCompat.createBlendModeColorFilterCompat(
|
||||
ColorUtil.withAlpha(
|
||||
newColor,
|
||||
0.65f
|
||||
), SRC_IN
|
||||
)
|
||||
}
|
||||
|
||||
fun hitTest(v: View, x: Int, y: Int): Boolean {
|
||||
val tx = (v.translationX + 0.5f).toInt()
|
||||
val ty = (v.translationY + 0.5f).toInt()
|
||||
val left = v.left + tx
|
||||
val right = v.right + tx
|
||||
val top = v.top + ty
|
||||
val bottom = v.bottom + ty
|
||||
|
||||
return x in left..right && y >= top && y <= bottom
|
||||
}
|
||||
|
||||
fun convertDpToPixel(dp: Float, resources: Resources): Float {
|
||||
val metrics = resources.displayMetrics
|
||||
return dp * metrics.density
|
||||
}
|
||||
}
|
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
* Copyright (C) 2017 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License
|
||||
*/
|
||||
package code.name.monkey.retromusic.util.color;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Bitmap.Config;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
|
||||
/**
|
||||
* Utility class for image analysis and processing.
|
||||
*
|
||||
* @hide
|
||||
*/
|
||||
public class ImageUtils {
|
||||
// Amount (max is 255) that two channels can differ before the color is no longer "gray".
|
||||
private static final int TOLERANCE = 20;
|
||||
// Alpha amount for which values below are considered transparent.
|
||||
private static final int ALPHA_TOLERANCE = 50;
|
||||
// Size of the smaller bitmap we're actually going to scan.
|
||||
private static final int COMPACT_BITMAP_SIZE = 64; // pixels
|
||||
private final Matrix mTempMatrix = new Matrix();
|
||||
private int[] mTempBuffer;
|
||||
private Bitmap mTempCompactBitmap;
|
||||
private Canvas mTempCompactBitmapCanvas;
|
||||
private Paint mTempCompactBitmapPaint;
|
||||
|
||||
/**
|
||||
* Classifies a color as grayscale or not. Grayscale here means "very close to a perfect gray"; if
|
||||
* all three channels are approximately equal, this will return true.
|
||||
*
|
||||
* <p>Note that really transparent colors are always grayscale.
|
||||
*/
|
||||
public static boolean isGrayscale(int color) {
|
||||
int alpha = 0xFF & (color >> 24);
|
||||
if (alpha < ALPHA_TOLERANCE) {
|
||||
return true;
|
||||
}
|
||||
int r = 0xFF & (color >> 16);
|
||||
int g = 0xFF & (color >> 8);
|
||||
int b = 0xFF & color;
|
||||
return Math.abs(r - g) < TOLERANCE
|
||||
&& Math.abs(r - b) < TOLERANCE
|
||||
&& Math.abs(g - b) < TOLERANCE;
|
||||
}
|
||||
|
||||
/** Convert a drawable to a bitmap, scaled to fit within maxWidth and maxHeight. */
|
||||
public static Bitmap buildScaledBitmap(Drawable drawable, int maxWidth, int maxHeight) {
|
||||
if (drawable == null) {
|
||||
return null;
|
||||
}
|
||||
int originalWidth = drawable.getIntrinsicWidth();
|
||||
int originalHeight = drawable.getIntrinsicHeight();
|
||||
if ((originalWidth <= maxWidth)
|
||||
&& (originalHeight <= maxHeight)
|
||||
&& (drawable instanceof BitmapDrawable)) {
|
||||
return ((BitmapDrawable) drawable).getBitmap();
|
||||
}
|
||||
if (originalHeight <= 0 || originalWidth <= 0) {
|
||||
return null;
|
||||
}
|
||||
// create a new bitmap, scaling down to fit the max dimensions of
|
||||
// a large notification icon if necessary
|
||||
float ratio =
|
||||
Math.min(
|
||||
(float) maxWidth / (float) originalWidth, (float) maxHeight / (float) originalHeight);
|
||||
ratio = Math.min(1.0f, ratio);
|
||||
int scaledWidth = (int) (ratio * originalWidth);
|
||||
int scaledHeight = (int) (ratio * originalHeight);
|
||||
Bitmap result = Bitmap.createBitmap(scaledWidth, scaledHeight, Config.ARGB_8888);
|
||||
// and paint our app bitmap on it
|
||||
Canvas canvas = new Canvas(result);
|
||||
drawable.setBounds(0, 0, scaledWidth, scaledHeight);
|
||||
drawable.draw(canvas);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a bitmap is grayscale. Grayscale here means "very close to a perfect gray".
|
||||
*
|
||||
* <p>Instead of scanning every pixel in the bitmap, we first resize the bitmap to no more than
|
||||
* COMPACT_BITMAP_SIZE^2 pixels using filtering. The hope is that any non-gray color elements will
|
||||
* survive the squeezing process, contaminating the result with color.
|
||||
*/
|
||||
public boolean isGrayscale(Bitmap bitmap) {
|
||||
int height = bitmap.getHeight();
|
||||
int width = bitmap.getWidth();
|
||||
|
||||
// shrink to a more manageable (yet hopefully no more or less colorful) size
|
||||
if (height > COMPACT_BITMAP_SIZE || width > COMPACT_BITMAP_SIZE) {
|
||||
if (mTempCompactBitmap == null) {
|
||||
mTempCompactBitmap =
|
||||
Bitmap.createBitmap(COMPACT_BITMAP_SIZE, COMPACT_BITMAP_SIZE, Config.ARGB_8888);
|
||||
mTempCompactBitmapCanvas = new Canvas(mTempCompactBitmap);
|
||||
mTempCompactBitmapPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
mTempCompactBitmapPaint.setFilterBitmap(true);
|
||||
}
|
||||
mTempMatrix.reset();
|
||||
mTempMatrix.setScale(
|
||||
(float) COMPACT_BITMAP_SIZE / width, (float) COMPACT_BITMAP_SIZE / height, 0, 0);
|
||||
mTempCompactBitmapCanvas.drawColor(0, PorterDuff.Mode.SRC); // select all, erase
|
||||
mTempCompactBitmapCanvas.drawBitmap(bitmap, mTempMatrix, mTempCompactBitmapPaint);
|
||||
bitmap = mTempCompactBitmap;
|
||||
width = height = COMPACT_BITMAP_SIZE;
|
||||
}
|
||||
final int size = height * width;
|
||||
ensureBufferSize(size);
|
||||
bitmap.getPixels(mTempBuffer, 0, width, 0, 0, width, height);
|
||||
for (int i = 0; i < size; i++) {
|
||||
if (!isGrayscale(mTempBuffer[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Makes sure that {@code mTempBuffer} has at least length {@code size}. */
|
||||
private void ensureBufferSize(int size) {
|
||||
if (mTempBuffer == null || mTempBuffer.length < size) {
|
||||
mTempBuffer = new int[size];
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,485 @@
|
|||
/*
|
||||
* Copyright (C) 2017 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License
|
||||
*/
|
||||
|
||||
package code.name.monkey.retromusic.util.color;
|
||||
|
||||
import static androidx.core.graphics.ColorUtils.RGBToXYZ;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Handler;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.FloatRange;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.palette.graphics.Palette;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import code.name.monkey.appthemehelper.util.ATHUtil;
|
||||
import code.name.monkey.appthemehelper.util.ColorUtil;
|
||||
import io.github.muntashirakon.music.R;
|
||||
|
||||
/** A class the processes media notifications and extracts the right text and background colors. */
|
||||
public class MediaNotificationProcessor {
|
||||
|
||||
/** The fraction below which we select the vibrant instead of the light/dark vibrant color */
|
||||
private static final float POPULATION_FRACTION_FOR_MORE_VIBRANT = 1.0f;
|
||||
|
||||
/**
|
||||
* Minimum saturation that a muted color must have if there exists if deciding between two colors
|
||||
*/
|
||||
private static final float MIN_SATURATION_WHEN_DECIDING = 0.19f;
|
||||
|
||||
/** Minimum fraction that any color must have to be picked up as a text color */
|
||||
private static final double MINIMUM_IMAGE_FRACTION = 0.002;
|
||||
|
||||
/**
|
||||
* The population fraction to select the dominant color as the text color over a the colored ones.
|
||||
*/
|
||||
private static final float POPULATION_FRACTION_FOR_DOMINANT = 0.01f;
|
||||
|
||||
/** The population fraction to select a white or black color as the background over a color. */
|
||||
private static final float POPULATION_FRACTION_FOR_WHITE_OR_BLACK = 2.5f;
|
||||
|
||||
private static final float BLACK_MAX_LIGHTNESS = 0.08f;
|
||||
private static final float WHITE_MIN_LIGHTNESS = 0.90f;
|
||||
private static final int RESIZE_BITMAP_AREA = 150 * 150;
|
||||
private static final ThreadLocal<double[]> TEMP_ARRAY = new ThreadLocal<>();
|
||||
/**
|
||||
* The lightness difference that has to be added to the primary text color to obtain the secondary
|
||||
* text color when the background is light.
|
||||
*/
|
||||
private static final int LIGHTNESS_TEXT_DIFFERENCE_LIGHT = 20;
|
||||
/**
|
||||
* The lightness difference that has to be added to the primary text color to obtain the secondary
|
||||
* text color when the background is dark. A bit less then the above value, since it looks better
|
||||
* on dark backgrounds.
|
||||
*/
|
||||
private static final int LIGHTNESS_TEXT_DIFFERENCE_DARK = -10;
|
||||
|
||||
private float[] mFilteredBackgroundHsl = null;
|
||||
private final Palette.Filter mBlackWhiteFilter =
|
||||
(rgb, hsl) -> !isWhiteOrBlack(hsl);
|
||||
private int backgroundColor;
|
||||
private int secondaryTextColor;
|
||||
private int primaryTextColor;
|
||||
private int actionBarColor;
|
||||
private Drawable drawable;
|
||||
private final Context context;
|
||||
|
||||
public MediaNotificationProcessor(Context context, Drawable drawable) {
|
||||
this.context = context;
|
||||
this.drawable = drawable;
|
||||
getMediaPalette();
|
||||
}
|
||||
|
||||
public MediaNotificationProcessor(Context context, Bitmap bitmap) {
|
||||
this.context = context;
|
||||
this.drawable = new BitmapDrawable(context.getResources(), bitmap);
|
||||
getMediaPalette();
|
||||
}
|
||||
|
||||
public MediaNotificationProcessor(Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
private static boolean isColorLight(int backgroundColor) {
|
||||
return calculateLuminance(backgroundColor) > 0.5f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the luminance of a color as a float between {@code 0.0} and {@code 1.0}.
|
||||
*
|
||||
* <p>Defined as the Y component in the XYZ representation of {@code color}.
|
||||
*/
|
||||
@FloatRange(from = 0.0, to = 1.0)
|
||||
private static double calculateLuminance(@ColorInt int color) {
|
||||
final double[] result = getTempDouble3Array();
|
||||
colorToXYZ(color, result);
|
||||
// Luminance is the Y component
|
||||
return result[1] / 100;
|
||||
}
|
||||
|
||||
private static double[] getTempDouble3Array() {
|
||||
double[] result = TEMP_ARRAY.get();
|
||||
if (result == null) {
|
||||
result = new double[3];
|
||||
TEMP_ARRAY.set(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void colorToXYZ(@ColorInt int color, @NonNull double[] outXyz) {
|
||||
RGBToXYZ(Color.red(color), Color.green(color), Color.blue(color), outXyz);
|
||||
}
|
||||
|
||||
public void getPaletteAsync(
|
||||
final OnPaletteLoadedListener onPaletteLoadedListener, Drawable drawable) {
|
||||
this.drawable = drawable;
|
||||
final Handler handler = new Handler();
|
||||
new Thread(
|
||||
() -> {
|
||||
getMediaPalette();
|
||||
handler.post(
|
||||
() -> onPaletteLoadedListener.onPaletteLoaded(MediaNotificationProcessor.this));
|
||||
})
|
||||
.start();
|
||||
}
|
||||
|
||||
public void getPaletteAsync(OnPaletteLoadedListener onPaletteLoadedListener, Bitmap bitmap) {
|
||||
this.drawable = new BitmapDrawable(context.getResources(), bitmap);
|
||||
getPaletteAsync(onPaletteLoadedListener, this.drawable);
|
||||
}
|
||||
|
||||
/** Processes a drawable and calculates the appropriate colors that should be used. */
|
||||
private void getMediaPalette() {
|
||||
Bitmap bitmap;
|
||||
if (drawable != null) {
|
||||
// We're transforming the builder, let's make sure all baked in RemoteViews are
|
||||
// rebuilt!
|
||||
|
||||
int width = drawable.getIntrinsicWidth();
|
||||
int height = drawable.getIntrinsicHeight();
|
||||
int area = width * height;
|
||||
if (area > RESIZE_BITMAP_AREA) {
|
||||
double factor = Math.sqrt((float) RESIZE_BITMAP_AREA / area);
|
||||
width = (int) (factor * width);
|
||||
height = (int) (factor * height);
|
||||
}
|
||||
bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
|
||||
Canvas canvas = new Canvas(bitmap);
|
||||
drawable.setBounds(0, 0, width, height);
|
||||
drawable.draw(canvas);
|
||||
|
||||
// for the background we only take the left side of the image to ensure
|
||||
// a smooth transition
|
||||
Palette.Builder paletteBuilder =
|
||||
Palette.from(bitmap)
|
||||
.setRegion(0, 0, bitmap.getWidth() / 2, bitmap.getHeight())
|
||||
.clearFilters() // we want all colors, red / white / black ones too!
|
||||
.resizeBitmapArea(RESIZE_BITMAP_AREA);
|
||||
Palette palette;
|
||||
backgroundColor = findBackgroundColorAndFilter(drawable);
|
||||
// we want most of the full region again, slightly shifted to the right
|
||||
float textColorStartWidthFraction = 0.4f;
|
||||
paletteBuilder.setRegion(
|
||||
(int) (bitmap.getWidth() * textColorStartWidthFraction),
|
||||
0,
|
||||
bitmap.getWidth(),
|
||||
bitmap.getHeight());
|
||||
if (mFilteredBackgroundHsl != null) {
|
||||
paletteBuilder.addFilter(
|
||||
(rgb, hsl) -> {
|
||||
// at least 10 degrees hue difference
|
||||
float diff = Math.abs(hsl[0] - mFilteredBackgroundHsl[0]);
|
||||
return diff > 10 && diff < 350;
|
||||
});
|
||||
}
|
||||
paletteBuilder.addFilter(mBlackWhiteFilter);
|
||||
palette = paletteBuilder.generate();
|
||||
int foregroundColor = selectForegroundColor(backgroundColor, palette);
|
||||
ensureColors(backgroundColor, foregroundColor);
|
||||
}
|
||||
}
|
||||
|
||||
private int selectForegroundColor(int backgroundColor, Palette palette) {
|
||||
if (isColorLight(backgroundColor)) {
|
||||
return selectForegroundColorForSwatches(
|
||||
palette.getDarkVibrantSwatch(),
|
||||
palette.getVibrantSwatch(),
|
||||
palette.getDarkMutedSwatch(),
|
||||
palette.getMutedSwatch(),
|
||||
palette.getDominantSwatch(),
|
||||
Color.BLACK);
|
||||
} else {
|
||||
return selectForegroundColorForSwatches(
|
||||
palette.getLightVibrantSwatch(),
|
||||
palette.getVibrantSwatch(),
|
||||
palette.getLightMutedSwatch(),
|
||||
palette.getMutedSwatch(),
|
||||
palette.getDominantSwatch(),
|
||||
Color.WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isLight() {
|
||||
return isColorLight(backgroundColor);
|
||||
}
|
||||
|
||||
private int selectForegroundColorForSwatches(
|
||||
Palette.Swatch moreVibrant,
|
||||
Palette.Swatch vibrant,
|
||||
Palette.Swatch moreMutedSwatch,
|
||||
Palette.Swatch mutedSwatch,
|
||||
Palette.Swatch dominantSwatch,
|
||||
int fallbackColor) {
|
||||
Palette.Swatch coloredCandidate = selectVibrantCandidate(moreVibrant, vibrant);
|
||||
if (coloredCandidate == null) {
|
||||
coloredCandidate = selectMutedCandidate(mutedSwatch, moreMutedSwatch);
|
||||
}
|
||||
if (coloredCandidate != null) {
|
||||
if (dominantSwatch == coloredCandidate) {
|
||||
return coloredCandidate.getRgb();
|
||||
} else if ((float) coloredCandidate.getPopulation() / dominantSwatch.getPopulation()
|
||||
< POPULATION_FRACTION_FOR_DOMINANT
|
||||
&& dominantSwatch.getHsl()[1] > MIN_SATURATION_WHEN_DECIDING) {
|
||||
return dominantSwatch.getRgb();
|
||||
} else {
|
||||
return coloredCandidate.getRgb();
|
||||
}
|
||||
} else if (hasEnoughPopulation(dominantSwatch)) {
|
||||
return dominantSwatch.getRgb();
|
||||
} else {
|
||||
return fallbackColor;
|
||||
}
|
||||
}
|
||||
|
||||
private Palette.Swatch selectMutedCandidate(Palette.Swatch first, Palette.Swatch second) {
|
||||
boolean firstValid = hasEnoughPopulation(first);
|
||||
boolean secondValid = hasEnoughPopulation(second);
|
||||
if (firstValid && secondValid) {
|
||||
float firstSaturation = first.getHsl()[1];
|
||||
float secondSaturation = second.getHsl()[1];
|
||||
float populationFraction = first.getPopulation() / (float) second.getPopulation();
|
||||
if (firstSaturation * populationFraction > secondSaturation) {
|
||||
return first;
|
||||
} else {
|
||||
return second;
|
||||
}
|
||||
} else if (firstValid) {
|
||||
return first;
|
||||
} else if (secondValid) {
|
||||
return second;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Palette.Swatch selectVibrantCandidate(Palette.Swatch first, Palette.Swatch second) {
|
||||
boolean firstValid = hasEnoughPopulation(first);
|
||||
boolean secondValid = hasEnoughPopulation(second);
|
||||
if (firstValid && secondValid) {
|
||||
int firstPopulation = first.getPopulation();
|
||||
int secondPopulation = second.getPopulation();
|
||||
if (firstPopulation / (float) secondPopulation < POPULATION_FRACTION_FOR_MORE_VIBRANT) {
|
||||
return second;
|
||||
} else {
|
||||
return first;
|
||||
}
|
||||
} else if (firstValid) {
|
||||
return first;
|
||||
} else if (secondValid) {
|
||||
return second;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean hasEnoughPopulation(Palette.Swatch swatch) {
|
||||
// We want a fraction that is at least 1% of the image
|
||||
return swatch != null
|
||||
&& (swatch.getPopulation() / (float) RESIZE_BITMAP_AREA > MINIMUM_IMAGE_FRACTION);
|
||||
}
|
||||
|
||||
public int findBackgroundColorAndFilter(Drawable drawable) {
|
||||
int width = drawable.getIntrinsicWidth();
|
||||
int height = drawable.getIntrinsicHeight();
|
||||
int area = width * height;
|
||||
|
||||
double factor = Math.sqrt((float) RESIZE_BITMAP_AREA / area);
|
||||
width = (int) (factor * width);
|
||||
height = (int) (factor * height);
|
||||
|
||||
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
|
||||
Canvas canvas = new Canvas(bitmap);
|
||||
drawable.setBounds(0, 0, width, height);
|
||||
drawable.draw(canvas);
|
||||
|
||||
// for the background we only take the left side of the image to ensure
|
||||
// a smooth transition
|
||||
Palette.Builder paletteBuilder =
|
||||
Palette.from(bitmap)
|
||||
.setRegion(0, 0, bitmap.getWidth() / 2, bitmap.getHeight())
|
||||
.clearFilters() // we want all colors, red / white / black ones too!
|
||||
.resizeBitmapArea(RESIZE_BITMAP_AREA);
|
||||
Palette palette = paletteBuilder.generate();
|
||||
// by default we use the dominant palette
|
||||
Palette.Swatch dominantSwatch = palette.getDominantSwatch();
|
||||
if (dominantSwatch == null) {
|
||||
// We're not filtering on white or black
|
||||
mFilteredBackgroundHsl = null;
|
||||
return Color.WHITE;
|
||||
}
|
||||
|
||||
if (!isWhiteOrBlack(dominantSwatch.getHsl())) {
|
||||
mFilteredBackgroundHsl = dominantSwatch.getHsl();
|
||||
return dominantSwatch.getRgb();
|
||||
}
|
||||
// Oh well, we selected black or white. Lets look at the second color!
|
||||
List<Palette.Swatch> swatches = palette.getSwatches();
|
||||
float highestNonWhitePopulation = -1;
|
||||
Palette.Swatch second = null;
|
||||
for (Palette.Swatch swatch : swatches) {
|
||||
if (swatch != dominantSwatch
|
||||
&& swatch.getPopulation() > highestNonWhitePopulation
|
||||
&& !isWhiteOrBlack(swatch.getHsl())) {
|
||||
second = swatch;
|
||||
highestNonWhitePopulation = swatch.getPopulation();
|
||||
}
|
||||
}
|
||||
if (second == null) {
|
||||
// We're not filtering on white or black
|
||||
mFilteredBackgroundHsl = null;
|
||||
return dominantSwatch.getRgb();
|
||||
}
|
||||
if (dominantSwatch.getPopulation() / highestNonWhitePopulation
|
||||
> POPULATION_FRACTION_FOR_WHITE_OR_BLACK) {
|
||||
// The dominant swatch is very dominant, lets take it!
|
||||
// We're not filtering on white or black
|
||||
mFilteredBackgroundHsl = null;
|
||||
return dominantSwatch.getRgb();
|
||||
} else {
|
||||
mFilteredBackgroundHsl = second.getHsl();
|
||||
return second.getRgb();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isWhiteOrBlack(float[] hsl) {
|
||||
return isBlack(hsl) || isWhite(hsl);
|
||||
}
|
||||
|
||||
/** @return true if the color represents a color which is close to black. */
|
||||
private boolean isBlack(float[] hslColor) {
|
||||
return hslColor[2] <= BLACK_MAX_LIGHTNESS;
|
||||
}
|
||||
|
||||
/** @return true if the color represents a color which is close to white. */
|
||||
private boolean isWhite(float[] hslColor) {
|
||||
return hslColor[2] >= WHITE_MIN_LIGHTNESS;
|
||||
}
|
||||
|
||||
private void ensureColors(int backgroundColor, int mForegroundColor) {
|
||||
{
|
||||
double backLum = NotificationColorUtil.calculateLuminance(backgroundColor);
|
||||
double textLum = NotificationColorUtil.calculateLuminance(mForegroundColor);
|
||||
double contrast = NotificationColorUtil.calculateContrast(mForegroundColor, backgroundColor);
|
||||
// We only respect the given colors if worst case Black or White still has
|
||||
// contrast
|
||||
boolean backgroundLight =
|
||||
backLum > textLum
|
||||
&& NotificationColorUtil.satisfiesTextContrast(backgroundColor, Color.BLACK)
|
||||
|| backLum <= textLum
|
||||
&& !NotificationColorUtil.satisfiesTextContrast(backgroundColor, Color.WHITE);
|
||||
if (contrast < 4.5f) {
|
||||
if (backgroundLight) {
|
||||
secondaryTextColor =
|
||||
NotificationColorUtil.findContrastColor(
|
||||
mForegroundColor, backgroundColor, true /* findFG */, 4.5f);
|
||||
primaryTextColor =
|
||||
NotificationColorUtil.changeColorLightness(
|
||||
secondaryTextColor, -LIGHTNESS_TEXT_DIFFERENCE_LIGHT);
|
||||
} else {
|
||||
secondaryTextColor =
|
||||
NotificationColorUtil.findContrastColorAgainstDark(
|
||||
mForegroundColor, backgroundColor, true /* findFG */, 4.5f);
|
||||
primaryTextColor =
|
||||
NotificationColorUtil.changeColorLightness(
|
||||
secondaryTextColor, -LIGHTNESS_TEXT_DIFFERENCE_DARK);
|
||||
}
|
||||
} else {
|
||||
primaryTextColor = mForegroundColor;
|
||||
secondaryTextColor =
|
||||
NotificationColorUtil.changeColorLightness(
|
||||
primaryTextColor,
|
||||
backgroundLight ? LIGHTNESS_TEXT_DIFFERENCE_LIGHT : LIGHTNESS_TEXT_DIFFERENCE_DARK);
|
||||
if (NotificationColorUtil.calculateContrast(secondaryTextColor, backgroundColor) < 4.5f) {
|
||||
// oh well the secondary is not good enough
|
||||
if (backgroundLight) {
|
||||
secondaryTextColor =
|
||||
NotificationColorUtil.findContrastColor(
|
||||
secondaryTextColor, backgroundColor, true /* findFG */, 4.5f);
|
||||
} else {
|
||||
secondaryTextColor =
|
||||
NotificationColorUtil.findContrastColorAgainstDark(
|
||||
secondaryTextColor, backgroundColor, true /* findFG */, 4.5f);
|
||||
}
|
||||
primaryTextColor =
|
||||
NotificationColorUtil.changeColorLightness(
|
||||
secondaryTextColor,
|
||||
backgroundLight
|
||||
? -LIGHTNESS_TEXT_DIFFERENCE_LIGHT
|
||||
: -LIGHTNESS_TEXT_DIFFERENCE_DARK);
|
||||
}
|
||||
}
|
||||
}
|
||||
actionBarColor = NotificationColorUtil.resolveActionBarColor(context, backgroundColor);
|
||||
}
|
||||
|
||||
public int getPrimaryTextColor() {
|
||||
return primaryTextColor;
|
||||
}
|
||||
|
||||
public int getSecondaryTextColor() {
|
||||
return secondaryTextColor;
|
||||
}
|
||||
|
||||
public int getActionBarColor() {
|
||||
return actionBarColor;
|
||||
}
|
||||
|
||||
public int getBackgroundColor() {
|
||||
return backgroundColor;
|
||||
}
|
||||
|
||||
boolean isWhiteColor(int color) {
|
||||
return calculateLuminance(color) > 0.6f;
|
||||
}
|
||||
|
||||
public int getMightyColor() {
|
||||
boolean isDarkBg =
|
||||
ColorUtil.INSTANCE.isColorLight(
|
||||
ATHUtil.INSTANCE.resolveColor(context, R.attr.colorSurface));
|
||||
if (isDarkBg) {
|
||||
if (isColorLight(backgroundColor)) {
|
||||
return primaryTextColor;
|
||||
} else {
|
||||
return backgroundColor;
|
||||
}
|
||||
} else {
|
||||
if (isColorLight(backgroundColor)) {
|
||||
return backgroundColor;
|
||||
} else {
|
||||
return primaryTextColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface OnPaletteLoadedListener {
|
||||
void onPaletteLoaded(MediaNotificationProcessor mediaNotificationProcessor);
|
||||
}
|
||||
|
||||
public static MediaNotificationProcessor errorColor(Context context) {
|
||||
MediaNotificationProcessor errorColors = new MediaNotificationProcessor(context);
|
||||
errorColors.backgroundColor = -15724528;
|
||||
errorColors.primaryTextColor = -6974059;
|
||||
errorColors.secondaryTextColor = -8684677;
|
||||
errorColors.actionBarColor = -6974059;
|
||||
return errorColors;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,992 @@
|
|||
/*
|
||||
* Copyright (C) 2017 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License
|
||||
*/
|
||||
|
||||
package code.name.monkey.retromusic.util.color;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Color;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.style.BackgroundColorSpan;
|
||||
import android.text.style.CharacterStyle;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.text.style.TextAppearanceSpan;
|
||||
import android.util.Log;
|
||||
import android.util.Pair;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.FloatRange;
|
||||
import androidx.annotation.IntRange;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import java.util.WeakHashMap;
|
||||
|
||||
import io.github.muntashirakon.music.R;
|
||||
|
||||
/**
|
||||
* Helper class to process legacy (Holo) notifications to make them look like material
|
||||
* notifications.
|
||||
*
|
||||
* @hide
|
||||
*/
|
||||
public class NotificationColorUtil {
|
||||
|
||||
private static final String TAG = "NotificationColorUtil";
|
||||
private static final boolean DEBUG = false;
|
||||
|
||||
private static final Object sLock = new Object();
|
||||
private static NotificationColorUtil sInstance;
|
||||
|
||||
private final ImageUtils mImageUtils = new ImageUtils();
|
||||
private final WeakHashMap<Bitmap, Pair<Boolean, Integer>> mGrayscaleBitmapCache =
|
||||
new WeakHashMap<>();
|
||||
|
||||
private final int mGrayscaleIconMaxSize; // @dimen/notification_large_icon_width (64dp)
|
||||
|
||||
private NotificationColorUtil(Context context) {
|
||||
mGrayscaleIconMaxSize =
|
||||
context.getResources().getDimensionPixelSize(R.dimen.notification_large_icon_width);
|
||||
}
|
||||
|
||||
public static NotificationColorUtil getInstance(Context context) {
|
||||
synchronized (sLock) {
|
||||
if (sInstance == null) {
|
||||
sInstance = new NotificationColorUtil(context);
|
||||
}
|
||||
return sInstance;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all color spans of a text
|
||||
*
|
||||
* @param charSequence the input text
|
||||
* @return the same text but without color spans
|
||||
*/
|
||||
public static CharSequence clearColorSpans(CharSequence charSequence) {
|
||||
if (charSequence instanceof Spanned) {
|
||||
Spanned ss = (Spanned) charSequence;
|
||||
Object[] spans = ss.getSpans(0, ss.length(), Object.class);
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder(ss.toString());
|
||||
for (Object span : spans) {
|
||||
Object resultSpan = span;
|
||||
if (resultSpan instanceof CharacterStyle) {
|
||||
resultSpan = ((CharacterStyle) span).getUnderlying();
|
||||
}
|
||||
if (resultSpan instanceof TextAppearanceSpan) {
|
||||
TextAppearanceSpan originalSpan = (TextAppearanceSpan) resultSpan;
|
||||
if (originalSpan.getTextColor() != null) {
|
||||
resultSpan =
|
||||
new TextAppearanceSpan(
|
||||
originalSpan.getFamily(),
|
||||
originalSpan.getTextStyle(),
|
||||
originalSpan.getTextSize(),
|
||||
null,
|
||||
originalSpan.getLinkTextColor());
|
||||
}
|
||||
} else if (resultSpan instanceof ForegroundColorSpan
|
||||
|| (resultSpan instanceof BackgroundColorSpan)) {
|
||||
continue;
|
||||
} else {
|
||||
resultSpan = span;
|
||||
}
|
||||
builder.setSpan(
|
||||
resultSpan, ss.getSpanStart(span), ss.getSpanEnd(span), ss.getSpanFlags(span));
|
||||
}
|
||||
return builder;
|
||||
}
|
||||
return charSequence;
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Inverts all the grayscale colors set by {@link android.text.style.TextAppearanceSpan}s on
|
||||
// * the text.
|
||||
// *
|
||||
// * @param charSequence The text to process.
|
||||
// * @return The color inverted text.
|
||||
// */
|
||||
// public CharSequence invertCharSequenceColors(CharSequence charSequence) {
|
||||
// if (charSequence instanceof Spanned) {
|
||||
// Spanned ss = (Spanned) charSequence;
|
||||
// Object[] spans = ss.getSpans(0, ss.length(), Object.class);
|
||||
// SpannableStringBuilder builder = new SpannableStringBuilder(ss.toString());
|
||||
// for (Object span : spans) {
|
||||
// Object resultSpan = span;
|
||||
// if (resultSpan instanceof CharacterStyle) {
|
||||
// resultSpan = ((CharacterStyle) span).getUnderlying();
|
||||
// }
|
||||
// if (resultSpan instanceof TextAppearanceSpan) {
|
||||
// TextAppearanceSpan processedSpan = processTextAppearanceSpan(
|
||||
// (TextAppearanceSpan) span);
|
||||
// if (processedSpan != resultSpan) {
|
||||
// resultSpan = processedSpan;
|
||||
// } else {
|
||||
// // we need to still take the orgininal for wrapped spans
|
||||
// resultSpan = span;
|
||||
// }
|
||||
// } else if (resultSpan instanceof ForegroundColorSpan) {
|
||||
// ForegroundColorSpan originalSpan = (ForegroundColorSpan) resultSpan;
|
||||
// int foregroundColor = originalSpan.getForegroundColor();
|
||||
// resultSpan = new ForegroundColorSpan(processColor(foregroundColor));
|
||||
// } else {
|
||||
// resultSpan = span;
|
||||
// }
|
||||
// builder.setSpan(resultSpan, ss.getSpanStart(span), ss.getSpanEnd(span),
|
||||
// ss.getSpanFlags(span));
|
||||
// }
|
||||
// return builder;
|
||||
// }
|
||||
// return charSequence;
|
||||
// }
|
||||
|
||||
// private TextAppearanceSpan processTextAppearanceSpan(TextAppearanceSpan span) {
|
||||
// ColorStateList colorStateList = span.getTextColor();
|
||||
// if (colorStateList != null) {
|
||||
// int[] colors = colorStateList.getColors();
|
||||
// boolean changed = false;
|
||||
// for (int i = 0; i < colors.length; i++) {
|
||||
// if (ImageUtils.isGrayscale(colors[i])) {
|
||||
//
|
||||
// // Allocate a new array so we don't change the colors in the old color state
|
||||
// // list.
|
||||
// if (!changed) {
|
||||
// colors = Arrays.copyOf(colors, colors.length);
|
||||
// }
|
||||
// colors[i] = processColor(colors[i]);
|
||||
// changed = true;
|
||||
// }
|
||||
// }
|
||||
// if (changed) {
|
||||
// return new TextAppearanceSpan(
|
||||
// span.getFamily(), span.getTextStyle(), span.getTextSize(),
|
||||
// new ColorStateList(colorStateList.getStates(), colors),
|
||||
// span.getLinkTextColor());
|
||||
// }
|
||||
// }
|
||||
// return span;
|
||||
// }
|
||||
|
||||
/**
|
||||
* Finds a suitable color such that there's enough contrast.
|
||||
*
|
||||
* @param color the color to start searching from.
|
||||
* @param other the color to ensure contrast against. Assumed to be lighter than {@param color}
|
||||
* @param findFg if true, we assume {@param color} is a foreground, otherwise a background.
|
||||
* @param minRatio the minimum contrast ratio required.
|
||||
* @return a color with the same hue as {@param color}, potentially darkened to meet the contrast
|
||||
* ratio.
|
||||
*/
|
||||
public static int findContrastColor(int color, int other, boolean findFg, double minRatio) {
|
||||
int fg = findFg ? color : other;
|
||||
int bg = findFg ? other : color;
|
||||
if (ColorUtilsFromCompat.calculateContrast(fg, bg) >= minRatio) {
|
||||
return color;
|
||||
}
|
||||
|
||||
double[] lab = new double[3];
|
||||
ColorUtilsFromCompat.colorToLAB(findFg ? fg : bg, lab);
|
||||
|
||||
double low = 0, high = lab[0];
|
||||
final double a = lab[1], b = lab[2];
|
||||
for (int i = 0; i < 15 && high - low > 0.00001; i++) {
|
||||
final double l = (low + high) / 2;
|
||||
if (findFg) {
|
||||
fg = ColorUtilsFromCompat.LABToColor(l, a, b);
|
||||
} else {
|
||||
bg = ColorUtilsFromCompat.LABToColor(l, a, b);
|
||||
}
|
||||
if (ColorUtilsFromCompat.calculateContrast(fg, bg) > minRatio) {
|
||||
low = l;
|
||||
} else {
|
||||
high = l;
|
||||
}
|
||||
}
|
||||
return ColorUtilsFromCompat.LABToColor(low, a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a suitable alpha such that there's enough contrast.
|
||||
*
|
||||
* @param color the color to start searching from.
|
||||
* @param backgroundColor the color to ensure contrast against.
|
||||
* @param minRatio the minimum contrast ratio required.
|
||||
* @return the same color as {@param color} with potentially modified alpha to meet contrast
|
||||
*/
|
||||
public static int findAlphaToMeetContrast(int color, int backgroundColor, double minRatio) {
|
||||
int fg = color;
|
||||
int bg = backgroundColor;
|
||||
if (ColorUtilsFromCompat.calculateContrast(fg, bg) >= minRatio) {
|
||||
return color;
|
||||
}
|
||||
int startAlpha = Color.alpha(color);
|
||||
int r = Color.red(color);
|
||||
int g = Color.green(color);
|
||||
int b = Color.blue(color);
|
||||
|
||||
int low = startAlpha, high = 255;
|
||||
for (int i = 0; i < 15 && high - low > 0; i++) {
|
||||
final int alpha = (low + high) / 2;
|
||||
fg = Color.argb(alpha, r, g, b);
|
||||
if (ColorUtilsFromCompat.calculateContrast(fg, bg) > minRatio) {
|
||||
high = alpha;
|
||||
} else {
|
||||
low = alpha;
|
||||
}
|
||||
}
|
||||
return Color.argb(high, r, g, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a suitable color such that there's enough contrast.
|
||||
*
|
||||
* @param color the color to start searching from.
|
||||
* @param other the color to ensure contrast against. Assumed to be darker than {@param color}
|
||||
* @param findFg if true, we assume {@param color} is a foreground, otherwise a background.
|
||||
* @param minRatio the minimum contrast ratio required.
|
||||
* @return a color with the same hue as {@param color}, potentially darkened to meet the contrast
|
||||
* ratio.
|
||||
*/
|
||||
public static int findContrastColorAgainstDark(
|
||||
int color, int other, boolean findFg, double minRatio) {
|
||||
int fg = findFg ? color : other;
|
||||
int bg = findFg ? other : color;
|
||||
if (ColorUtilsFromCompat.calculateContrast(fg, bg) >= minRatio) {
|
||||
return color;
|
||||
}
|
||||
|
||||
float[] hsl = new float[3];
|
||||
ColorUtilsFromCompat.colorToHSL(findFg ? fg : bg, hsl);
|
||||
|
||||
float low = hsl[2], high = 1;
|
||||
for (int i = 0; i < 15 && high - low > 0.00001; i++) {
|
||||
final float l = (low + high) / 2;
|
||||
hsl[2] = l;
|
||||
if (findFg) {
|
||||
fg = ColorUtilsFromCompat.HSLToColor(hsl);
|
||||
} else {
|
||||
bg = ColorUtilsFromCompat.HSLToColor(hsl);
|
||||
}
|
||||
if (ColorUtilsFromCompat.calculateContrast(fg, bg) > minRatio) {
|
||||
high = l;
|
||||
} else {
|
||||
low = l;
|
||||
}
|
||||
}
|
||||
return findFg ? fg : bg;
|
||||
}
|
||||
|
||||
public static int ensureTextContrastOnBlack(int color) {
|
||||
return findContrastColorAgainstDark(color, Color.BLACK, true /* fg */, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a large text color with sufficient contrast over bg that has the same or darker hue as
|
||||
* the original color, depending on the value of {@code isBgDarker}.
|
||||
*
|
||||
* @param isBgDarker {@code true} if {@code bg} is darker than {@code color}.
|
||||
*/
|
||||
public static int ensureLargeTextContrast(int color, int bg, boolean isBgDarker) {
|
||||
return isBgDarker
|
||||
? findContrastColorAgainstDark(color, bg, true, 3)
|
||||
: findContrastColor(color, bg, true, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a text color with sufficient contrast over bg that has the same or darker hue as the
|
||||
* original color, depending on the value of {@code isBgDarker}.
|
||||
*
|
||||
* @param isBgDarker {@code true} if {@code bg} is darker than {@code color}.
|
||||
*/
|
||||
private static int ensureTextContrast(int color, int bg, boolean isBgDarker) {
|
||||
return isBgDarker
|
||||
? findContrastColorAgainstDark(color, bg, true, 4.5)
|
||||
: findContrastColor(color, bg, true, 4.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a background color for a text view with given text color and hint text color, that has
|
||||
* the same hue as the original color.
|
||||
*/
|
||||
public static int ensureTextBackgroundColor(int color, int textColor, int hintColor) {
|
||||
color = findContrastColor(color, hintColor, false, 3.0);
|
||||
return findContrastColor(color, textColor, false, 4.5);
|
||||
}
|
||||
|
||||
private static String contrastChange(int colorOld, int colorNew, int bg) {
|
||||
return String.format(
|
||||
"from %.2f:1 to %.2f:1",
|
||||
ColorUtilsFromCompat.calculateContrast(colorOld, bg),
|
||||
ColorUtilsFromCompat.calculateContrast(colorNew, bg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Change a color by a specified value
|
||||
*
|
||||
* @param baseColor the base color to lighten
|
||||
* @param amount the amount to lighten the color from 0 to 100. This corresponds to the L increase
|
||||
* in the LAB color space. A negative value will darken the color and a positive will lighten
|
||||
* it.
|
||||
* @return the changed color
|
||||
*/
|
||||
public static int changeColorLightness(int baseColor, int amount) {
|
||||
final double[] result = ColorUtilsFromCompat.getTempDouble3Array();
|
||||
ColorUtilsFromCompat.colorToLAB(baseColor, result);
|
||||
result[0] = Math.max(Math.min(100, result[0] + amount), 0);
|
||||
return ColorUtilsFromCompat.LABToColor(result[0], result[1], result[2]);
|
||||
}
|
||||
|
||||
public static int resolvePrimaryColor(Context context, int backgroundColor) {
|
||||
boolean useDark = shouldUseDark(backgroundColor);
|
||||
return ContextCompat.getColor(context, android.R.color.primary_text_light);
|
||||
}
|
||||
|
||||
public static int resolveSecondaryColor(Context context, int backgroundColor) {
|
||||
boolean useDark = shouldUseDark(backgroundColor);
|
||||
if (useDark) {
|
||||
return ContextCompat.getColor(context, android.R.color.secondary_text_light);
|
||||
} else {
|
||||
return ContextCompat.getColor(context, android.R.color.secondary_text_dark);
|
||||
}
|
||||
}
|
||||
|
||||
public static int resolveActionBarColor(Context context, int backgroundColor) {
|
||||
if (backgroundColor == Notification.COLOR_DEFAULT) {
|
||||
return Color.BLACK;
|
||||
}
|
||||
return getShiftedColor(backgroundColor, 7);
|
||||
}
|
||||
|
||||
/** Resolves {@param color} to an actual color if it is {@link Notification#COLOR_DEFAULT} */
|
||||
public static int resolveColor(Context context, int color) {
|
||||
if (color == Notification.COLOR_DEFAULT) {
|
||||
return ContextCompat.getColor(context, android.R.color.background_dark);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
//
|
||||
// public static int resolveContrastColor(Context context, int notificationColor,
|
||||
// int backgroundColor) {
|
||||
// return NotificationColorUtil.resolveContrastColor(context, notificationColor,
|
||||
// backgroundColor, false /* isDark */);
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Resolves a Notification's color such that it has enough contrast to be used as the
|
||||
// * color for the Notification's action and header text.
|
||||
// *
|
||||
// * @param notificationColor the color of the notification or {@link
|
||||
// Notification#COLOR_DEFAULT}
|
||||
// * @param backgroundColor the background color to ensure the contrast against.
|
||||
// * @param isDark whether or not the {@code notificationColor} will be placed on a background
|
||||
// * that is darker than the color itself
|
||||
// * @return a color of the same hue with enough contrast against the backgrounds.
|
||||
// */
|
||||
// public static int resolveContrastColor(Context context, int notificationColor,
|
||||
// int backgroundColor, boolean isDark) {
|
||||
// final int resolvedColor = resolveColor(context, notificationColor);
|
||||
//
|
||||
// final int actionBg = context.getColor(
|
||||
// com.android.internal.R.color.notification_action_list);
|
||||
//
|
||||
// int color = resolvedColor;
|
||||
// color = NotificationColorUtil.ensureLargeTextContrast(color, actionBg, isDark);
|
||||
// color = NotificationColorUtil.ensureTextContrast(color, backgroundColor, isDark);
|
||||
//
|
||||
// if (color != resolvedColor) {
|
||||
// if (DEBUG){
|
||||
// Log.w(TAG, String.format(
|
||||
// "Enhanced contrast of notification for %s %s (over action)"
|
||||
// + " and %s (over background) by changing #%s to %s",
|
||||
// context.getPackageName(),
|
||||
// NotificationColorUtil.contrastChange(resolvedColor, color, actionBg),
|
||||
// NotificationColorUtil.contrastChange(resolvedColor, color,
|
||||
// backgroundColor),
|
||||
// Integer.toHexString(resolvedColor), Integer.toHexString(color)));
|
||||
// }
|
||||
// }
|
||||
// return color;
|
||||
// }
|
||||
|
||||
/**
|
||||
* Get a color that stays in the same tint, but darkens or lightens it by a certain amount. This
|
||||
* also looks at the lightness of the provided color and shifts it appropriately.
|
||||
*
|
||||
* @param color the base color to use
|
||||
* @param amount the amount from 1 to 100 how much to modify the color
|
||||
* @return the now color that was modified
|
||||
*/
|
||||
public static int getShiftedColor(int color, int amount) {
|
||||
final double[] result = ColorUtilsFromCompat.getTempDouble3Array();
|
||||
ColorUtilsFromCompat.colorToLAB(color, result);
|
||||
if (result[0] >= 4) {
|
||||
result[0] = Math.max(0, result[0] - amount);
|
||||
} else {
|
||||
result[0] = Math.min(100, result[0] + amount);
|
||||
}
|
||||
return ColorUtilsFromCompat.LABToColor(result[0], result[1], result[2]);
|
||||
}
|
||||
|
||||
public static int resolveAmbientColor(Context context, int notificationColor) {
|
||||
final int resolvedColor = resolveColor(context, notificationColor);
|
||||
|
||||
int color = resolvedColor;
|
||||
color = NotificationColorUtil.ensureTextContrastOnBlack(color);
|
||||
|
||||
if (color != resolvedColor) {
|
||||
if (DEBUG) {
|
||||
Log.w(
|
||||
TAG,
|
||||
String.format(
|
||||
"Ambient contrast of notification for %s is %s (over black)"
|
||||
+ " by changing #%s to #%s",
|
||||
context.getPackageName(),
|
||||
NotificationColorUtil.contrastChange(resolvedColor, color, Color.BLACK),
|
||||
Integer.toHexString(resolvedColor),
|
||||
Integer.toHexString(color)));
|
||||
}
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
private static boolean shouldUseDark(int backgroundColor) {
|
||||
boolean useDark = backgroundColor == Notification.COLOR_DEFAULT;
|
||||
if (!useDark) {
|
||||
useDark = ColorUtilsFromCompat.calculateLuminance(backgroundColor) > 0.5;
|
||||
}
|
||||
return useDark;
|
||||
}
|
||||
|
||||
public static double calculateLuminance(int backgroundColor) {
|
||||
return ColorUtilsFromCompat.calculateLuminance(backgroundColor);
|
||||
}
|
||||
|
||||
public static double calculateContrast(int foregroundColor, int backgroundColor) {
|
||||
return ColorUtilsFromCompat.calculateContrast(foregroundColor, backgroundColor);
|
||||
}
|
||||
|
||||
public static boolean satisfiesTextContrast(int backgroundColor, int foregroundColor) {
|
||||
return NotificationColorUtil.calculateContrast(foregroundColor, backgroundColor) >= 4.5;
|
||||
}
|
||||
|
||||
/** Composite two potentially translucent colors over each other and returns the result. */
|
||||
public static int compositeColors(int foreground, int background) {
|
||||
return ColorUtilsFromCompat.compositeColors(foreground, background);
|
||||
}
|
||||
|
||||
public static boolean isColorLight(int backgroundColor) {
|
||||
return calculateLuminance(backgroundColor) > 0.5f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a Bitmap is a small grayscale icon. Grayscale here means "very close to a
|
||||
* perfect gray"; icon means "no larger than 64dp".
|
||||
*
|
||||
* @param bitmap The bitmap to test.
|
||||
* @return True if the bitmap is grayscale; false if it is color or too large to examine.
|
||||
*/
|
||||
public boolean isGrayscaleIcon(Bitmap bitmap) {
|
||||
// quick test: reject large bitmaps
|
||||
if (bitmap.getWidth() > mGrayscaleIconMaxSize || bitmap.getHeight() > mGrayscaleIconMaxSize) {
|
||||
return false;
|
||||
}
|
||||
|
||||
synchronized (sLock) {
|
||||
Pair<Boolean, Integer> cached = mGrayscaleBitmapCache.get(bitmap);
|
||||
if (cached != null) {
|
||||
if (cached.second == bitmap.getGenerationId()) {
|
||||
return cached.first;
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean result;
|
||||
int generationId;
|
||||
synchronized (mImageUtils) {
|
||||
result = mImageUtils.isGrayscale(bitmap);
|
||||
|
||||
// generationId and the check whether the Bitmap is grayscale can't be read atomically
|
||||
// here. However, since the thread is in the process of posting the notification, we can
|
||||
// assume that it doesn't modify the bitmap while we are checking the pixels.
|
||||
generationId = bitmap.getGenerationId();
|
||||
}
|
||||
synchronized (sLock) {
|
||||
mGrayscaleBitmapCache.put(bitmap, Pair.create(result, generationId));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private int processColor(int color) {
|
||||
return Color.argb(
|
||||
Color.alpha(color),
|
||||
255 - Color.red(color),
|
||||
255 - Color.green(color),
|
||||
255 - Color.blue(color));
|
||||
}
|
||||
|
||||
/** Framework copy of functions needed from android.support.v4.graphics.ColorUtils. */
|
||||
private static class ColorUtilsFromCompat {
|
||||
private static final double XYZ_WHITE_REFERENCE_X = 95.047;
|
||||
private static final double XYZ_WHITE_REFERENCE_Y = 100;
|
||||
private static final double XYZ_WHITE_REFERENCE_Z = 108.883;
|
||||
private static final double XYZ_EPSILON = 0.008856;
|
||||
private static final double XYZ_KAPPA = 903.3;
|
||||
|
||||
private static final int MIN_ALPHA_SEARCH_MAX_ITERATIONS = 10;
|
||||
private static final int MIN_ALPHA_SEARCH_PRECISION = 1;
|
||||
|
||||
private static final ThreadLocal<double[]> TEMP_ARRAY = new ThreadLocal<>();
|
||||
|
||||
private ColorUtilsFromCompat() {}
|
||||
|
||||
/** Composite two potentially translucent colors over each other and returns the result. */
|
||||
public static int compositeColors(@ColorInt int foreground, @ColorInt int background) {
|
||||
int bgAlpha = Color.alpha(background);
|
||||
int fgAlpha = Color.alpha(foreground);
|
||||
int a = compositeAlpha(fgAlpha, bgAlpha);
|
||||
|
||||
int r = compositeComponent(Color.red(foreground), fgAlpha, Color.red(background), bgAlpha, a);
|
||||
int g =
|
||||
compositeComponent(Color.green(foreground), fgAlpha, Color.green(background), bgAlpha, a);
|
||||
int b =
|
||||
compositeComponent(Color.blue(foreground), fgAlpha, Color.blue(background), bgAlpha, a);
|
||||
|
||||
return Color.argb(a, r, g, b);
|
||||
}
|
||||
|
||||
private static int compositeAlpha(int foregroundAlpha, int backgroundAlpha) {
|
||||
return 0xFF - (((0xFF - backgroundAlpha) * (0xFF - foregroundAlpha)) / 0xFF);
|
||||
}
|
||||
|
||||
private static int compositeComponent(int fgC, int fgA, int bgC, int bgA, int a) {
|
||||
if (a == 0) return 0;
|
||||
return ((0xFF * fgC * fgA) + (bgC * bgA * (0xFF - fgA))) / (a * 0xFF);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the luminance of a color as a float between {@code 0.0} and {@code 1.0}.
|
||||
*
|
||||
* <p>Defined as the Y component in the XYZ representation of {@code color}.
|
||||
*/
|
||||
@FloatRange(from = 0.0, to = 1.0)
|
||||
public static double calculateLuminance(@ColorInt int color) {
|
||||
final double[] result = getTempDouble3Array();
|
||||
colorToXYZ(color, result);
|
||||
// Luminance is the Y component
|
||||
return result[1] / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the contrast ratio between {@code foreground} and {@code background}. {@code
|
||||
* background} must be opaque.
|
||||
*
|
||||
* <p>Formula defined <a
|
||||
* href="http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef">here</a>.
|
||||
*/
|
||||
public static double calculateContrast(@ColorInt int foreground, @ColorInt int background) {
|
||||
if (Color.alpha(background) != 255) {
|
||||
Log.wtf(TAG, "background can not be translucent: #" + Integer.toHexString(background));
|
||||
}
|
||||
if (Color.alpha(foreground) < 255) {
|
||||
// If the foreground is translucent, composite the foreground over the background
|
||||
foreground = compositeColors(foreground, background);
|
||||
}
|
||||
|
||||
final double luminance1 = calculateLuminance(foreground) + 0.05;
|
||||
final double luminance2 = calculateLuminance(background) + 0.05;
|
||||
|
||||
// Now return the lighter luminance divided by the darker luminance
|
||||
return Math.max(luminance1, luminance2) / Math.min(luminance1, luminance2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the ARGB color to its CIE Lab representative components.
|
||||
*
|
||||
* @param color the ARGB color to convert. The alpha component is ignored
|
||||
* @param outLab 3-element array which holds the resulting LAB components
|
||||
*/
|
||||
public static void colorToLAB(@ColorInt int color, @NonNull double[] outLab) {
|
||||
RGBToLAB(Color.red(color), Color.green(color), Color.blue(color), outLab);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB components to its CIE Lab representative components.
|
||||
*
|
||||
* <ul>
|
||||
* <li>outLab[0] is L [0 ...100)
|
||||
* <li>outLab[1] is a [-128...127)
|
||||
* <li>outLab[2] is b [-128...127)
|
||||
* </ul>
|
||||
*
|
||||
* @param r red component value [0..255]
|
||||
* @param g green component value [0..255]
|
||||
* @param b blue component value [0..255]
|
||||
* @param outLab 3-element array which holds the resulting LAB components
|
||||
*/
|
||||
public static void RGBToLAB(
|
||||
@IntRange(from = 0x0, to = 0xFF) int r,
|
||||
@IntRange(from = 0x0, to = 0xFF) int g,
|
||||
@IntRange(from = 0x0, to = 0xFF) int b,
|
||||
@NonNull double[] outLab) {
|
||||
// First we convert RGB to XYZ
|
||||
RGBToXYZ(r, g, b, outLab);
|
||||
// outLab now contains XYZ
|
||||
XYZToLAB(outLab[0], outLab[1], outLab[2], outLab);
|
||||
// outLab now contains LAB representation
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the ARGB color to it's CIE XYZ representative components.
|
||||
*
|
||||
* <p>The resulting XYZ representation will use the D65 illuminant and the CIE 2° Standard
|
||||
* Observer (1931).
|
||||
*
|
||||
* <ul>
|
||||
* <li>outXyz[0] is X [0 ...95.047)
|
||||
* <li>outXyz[1] is Y [0...100)
|
||||
* <li>outXyz[2] is Z [0...108.883)
|
||||
* </ul>
|
||||
*
|
||||
* @param color the ARGB color to convert. The alpha component is ignored
|
||||
* @param outXyz 3-element array which holds the resulting LAB components
|
||||
*/
|
||||
public static void colorToXYZ(@ColorInt int color, @NonNull double[] outXyz) {
|
||||
RGBToXYZ(Color.red(color), Color.green(color), Color.blue(color), outXyz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB components to it's CIE XYZ representative components.
|
||||
*
|
||||
* <p>The resulting XYZ representation will use the D65 illuminant and the CIE 2° Standard
|
||||
* Observer (1931).
|
||||
*
|
||||
* <ul>
|
||||
* <li>outXyz[0] is X [0 ...95.047)
|
||||
* <li>outXyz[1] is Y [0...100)
|
||||
* <li>outXyz[2] is Z [0...108.883)
|
||||
* </ul>
|
||||
*
|
||||
* @param r red component value [0..255]
|
||||
* @param g green component value [0..255]
|
||||
* @param b blue component value [0..255]
|
||||
* @param outXyz 3-element array which holds the resulting XYZ components
|
||||
*/
|
||||
public static void RGBToXYZ(
|
||||
@IntRange(from = 0x0, to = 0xFF) int r,
|
||||
@IntRange(from = 0x0, to = 0xFF) int g,
|
||||
@IntRange(from = 0x0, to = 0xFF) int b,
|
||||
@NonNull double[] outXyz) {
|
||||
if (outXyz.length != 3) {
|
||||
throw new IllegalArgumentException("outXyz must have a length of 3.");
|
||||
}
|
||||
|
||||
double sr = r / 255.0;
|
||||
sr = sr < 0.04045 ? sr / 12.92 : Math.pow((sr + 0.055) / 1.055, 2.4);
|
||||
double sg = g / 255.0;
|
||||
sg = sg < 0.04045 ? sg / 12.92 : Math.pow((sg + 0.055) / 1.055, 2.4);
|
||||
double sb = b / 255.0;
|
||||
sb = sb < 0.04045 ? sb / 12.92 : Math.pow((sb + 0.055) / 1.055, 2.4);
|
||||
|
||||
outXyz[0] = 100 * (sr * 0.4124 + sg * 0.3576 + sb * 0.1805);
|
||||
outXyz[1] = 100 * (sr * 0.2126 + sg * 0.7152 + sb * 0.0722);
|
||||
outXyz[2] = 100 * (sr * 0.0193 + sg * 0.1192 + sb * 0.9505);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a color from CIE XYZ to CIE Lab representation.
|
||||
*
|
||||
* <p>This method expects the XYZ representation to use the D65 illuminant and the CIE 2°
|
||||
* Standard Observer (1931).
|
||||
*
|
||||
* <ul>
|
||||
* <li>outLab[0] is L [0 ...100)
|
||||
* <li>outLab[1] is a [-128...127)
|
||||
* <li>outLab[2] is b [-128...127)
|
||||
* </ul>
|
||||
*
|
||||
* @param x X component value [0...95.047)
|
||||
* @param y Y component value [0...100)
|
||||
* @param z Z component value [0...108.883)
|
||||
* @param outLab 3-element array which holds the resulting Lab components
|
||||
*/
|
||||
public static void XYZToLAB(
|
||||
@FloatRange(from = 0f, to = XYZ_WHITE_REFERENCE_X) double x,
|
||||
@FloatRange(from = 0f, to = XYZ_WHITE_REFERENCE_Y) double y,
|
||||
@FloatRange(from = 0f, to = XYZ_WHITE_REFERENCE_Z) double z,
|
||||
@NonNull double[] outLab) {
|
||||
if (outLab.length != 3) {
|
||||
throw new IllegalArgumentException("outLab must have a length of 3.");
|
||||
}
|
||||
x = pivotXyzComponent(x / XYZ_WHITE_REFERENCE_X);
|
||||
y = pivotXyzComponent(y / XYZ_WHITE_REFERENCE_Y);
|
||||
z = pivotXyzComponent(z / XYZ_WHITE_REFERENCE_Z);
|
||||
outLab[0] = Math.max(0, 116 * y - 16);
|
||||
outLab[1] = 500 * (x - y);
|
||||
outLab[2] = 200 * (y - z);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a color from CIE Lab to CIE XYZ representation.
|
||||
*
|
||||
* <p>The resulting XYZ representation will use the D65 illuminant and the CIE 2° Standard
|
||||
* Observer (1931).
|
||||
*
|
||||
* <ul>
|
||||
* <li>outXyz[0] is X [0 ...95.047)
|
||||
* <li>outXyz[1] is Y [0...100)
|
||||
* <li>outXyz[2] is Z [0...108.883)
|
||||
* </ul>
|
||||
*
|
||||
* @param l L component value [0...100)
|
||||
* @param a A component value [-128...127)
|
||||
* @param b B component value [-128...127)
|
||||
* @param outXyz 3-element array which holds the resulting XYZ components
|
||||
*/
|
||||
public static void LABToXYZ(
|
||||
@FloatRange(from = 0f, to = 100) final double l,
|
||||
@FloatRange(from = -128, to = 127) final double a,
|
||||
@FloatRange(from = -128, to = 127) final double b,
|
||||
@NonNull double[] outXyz) {
|
||||
final double fy = (l + 16) / 116;
|
||||
final double fx = a / 500 + fy;
|
||||
final double fz = fy - b / 200;
|
||||
|
||||
double tmp = Math.pow(fx, 3);
|
||||
final double xr = tmp > XYZ_EPSILON ? tmp : (116 * fx - 16) / XYZ_KAPPA;
|
||||
final double yr = l > XYZ_KAPPA * XYZ_EPSILON ? Math.pow(fy, 3) : l / XYZ_KAPPA;
|
||||
|
||||
tmp = Math.pow(fz, 3);
|
||||
final double zr = tmp > XYZ_EPSILON ? tmp : (116 * fz - 16) / XYZ_KAPPA;
|
||||
|
||||
outXyz[0] = xr * XYZ_WHITE_REFERENCE_X;
|
||||
outXyz[1] = yr * XYZ_WHITE_REFERENCE_Y;
|
||||
outXyz[2] = zr * XYZ_WHITE_REFERENCE_Z;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a color from CIE XYZ to its RGB representation.
|
||||
*
|
||||
* <p>This method expects the XYZ representation to use the D65 illuminant and the CIE 2°
|
||||
* Standard Observer (1931).
|
||||
*
|
||||
* @param x X component value [0...95.047)
|
||||
* @param y Y component value [0...100)
|
||||
* @param z Z component value [0...108.883)
|
||||
* @return int containing the RGB representation
|
||||
*/
|
||||
@ColorInt
|
||||
public static int XYZToColor(
|
||||
@FloatRange(from = 0f, to = XYZ_WHITE_REFERENCE_X) double x,
|
||||
@FloatRange(from = 0f, to = XYZ_WHITE_REFERENCE_Y) double y,
|
||||
@FloatRange(from = 0f, to = XYZ_WHITE_REFERENCE_Z) double z) {
|
||||
double r = (x * 3.2406 + y * -1.5372 + z * -0.4986) / 100;
|
||||
double g = (x * -0.9689 + y * 1.8758 + z * 0.0415) / 100;
|
||||
double b = (x * 0.0557 + y * -0.2040 + z * 1.0570) / 100;
|
||||
|
||||
r = r > 0.0031308 ? 1.055 * Math.pow(r, 1 / 2.4) - 0.055 : 12.92 * r;
|
||||
g = g > 0.0031308 ? 1.055 * Math.pow(g, 1 / 2.4) - 0.055 : 12.92 * g;
|
||||
b = b > 0.0031308 ? 1.055 * Math.pow(b, 1 / 2.4) - 0.055 : 12.92 * b;
|
||||
|
||||
return Color.rgb(
|
||||
constrain((int) Math.round(r * 255), 0, 255),
|
||||
constrain((int) Math.round(g * 255), 0, 255),
|
||||
constrain((int) Math.round(b * 255), 0, 255));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a color from CIE Lab to its RGB representation.
|
||||
*
|
||||
* @param l L component value [0...100]
|
||||
* @param a A component value [-128...127]
|
||||
* @param b B component value [-128...127]
|
||||
* @return int containing the RGB representation
|
||||
*/
|
||||
@ColorInt
|
||||
public static int LABToColor(
|
||||
@FloatRange(from = 0f, to = 100) final double l,
|
||||
@FloatRange(from = -128, to = 127) final double a,
|
||||
@FloatRange(from = -128, to = 127) final double b) {
|
||||
final double[] result = getTempDouble3Array();
|
||||
LABToXYZ(l, a, b, result);
|
||||
return XYZToColor(result[0], result[1], result[2]);
|
||||
}
|
||||
|
||||
private static int constrain(int amount, int low, int high) {
|
||||
return amount < low ? low : (Math.min(amount, high));
|
||||
}
|
||||
|
||||
private static float constrain(float amount, float low, float high) {
|
||||
return amount < low ? low : (Math.min(amount, high));
|
||||
}
|
||||
|
||||
private static double pivotXyzComponent(double component) {
|
||||
return component > XYZ_EPSILON
|
||||
? Math.pow(component, 1 / 3.0)
|
||||
: (XYZ_KAPPA * component + 16) / 116;
|
||||
}
|
||||
|
||||
public static double[] getTempDouble3Array() {
|
||||
double[] result = TEMP_ARRAY.get();
|
||||
if (result == null) {
|
||||
result = new double[3];
|
||||
TEMP_ARRAY.set(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HSL (hue-saturation-lightness) components to a RGB color.
|
||||
*
|
||||
* <ul>
|
||||
* <li>hsl[0] is Hue [0 .. 360)
|
||||
* <li>hsl[1] is Saturation [0...1]
|
||||
* <li>hsl[2] is Lightness [0...1]
|
||||
* </ul>
|
||||
*
|
||||
* If hsv values are out of range, they are pinned.
|
||||
*
|
||||
* @param hsl 3-element array which holds the input HSL components
|
||||
* @return the resulting RGB color
|
||||
*/
|
||||
@ColorInt
|
||||
public static int HSLToColor(@NonNull float[] hsl) {
|
||||
final float h = hsl[0];
|
||||
final float s = hsl[1];
|
||||
final float l = hsl[2];
|
||||
|
||||
final float c = (1f - Math.abs(2 * l - 1f)) * s;
|
||||
final float m = l - 0.5f * c;
|
||||
final float x = c * (1f - Math.abs((h / 60f % 2f) - 1f));
|
||||
|
||||
final int hueSegment = (int) h / 60;
|
||||
|
||||
int r = 0, g = 0, b = 0;
|
||||
|
||||
switch (hueSegment) {
|
||||
case 0:
|
||||
r = Math.round(255 * (c + m));
|
||||
g = Math.round(255 * (x + m));
|
||||
b = Math.round(255 * m);
|
||||
break;
|
||||
case 1:
|
||||
r = Math.round(255 * (x + m));
|
||||
g = Math.round(255 * (c + m));
|
||||
b = Math.round(255 * m);
|
||||
break;
|
||||
case 2:
|
||||
r = Math.round(255 * m);
|
||||
g = Math.round(255 * (c + m));
|
||||
b = Math.round(255 * (x + m));
|
||||
break;
|
||||
case 3:
|
||||
r = Math.round(255 * m);
|
||||
g = Math.round(255 * (x + m));
|
||||
b = Math.round(255 * (c + m));
|
||||
break;
|
||||
case 4:
|
||||
r = Math.round(255 * (x + m));
|
||||
g = Math.round(255 * m);
|
||||
b = Math.round(255 * (c + m));
|
||||
break;
|
||||
case 5:
|
||||
case 6:
|
||||
r = Math.round(255 * (c + m));
|
||||
g = Math.round(255 * m);
|
||||
b = Math.round(255 * (x + m));
|
||||
break;
|
||||
}
|
||||
|
||||
r = constrain(r, 0, 255);
|
||||
g = constrain(g, 0, 255);
|
||||
b = constrain(b, 0, 255);
|
||||
|
||||
return Color.rgb(r, g, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the ARGB color to its HSL (hue-saturation-lightness) components.
|
||||
*
|
||||
* <ul>
|
||||
* <li>outHsl[0] is Hue [0 .. 360)
|
||||
* <li>outHsl[1] is Saturation [0...1]
|
||||
* <li>outHsl[2] is Lightness [0...1]
|
||||
* </ul>
|
||||
*
|
||||
* @param color the ARGB color to convert. The alpha component is ignored
|
||||
* @param outHsl 3-element array which holds the resulting HSL components
|
||||
*/
|
||||
public static void colorToHSL(@ColorInt int color, @NonNull float[] outHsl) {
|
||||
RGBToHSL(Color.red(color), Color.green(color), Color.blue(color), outHsl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB components to HSL (hue-saturation-lightness).
|
||||
*
|
||||
* <ul>
|
||||
* <li>outHsl[0] is Hue [0 .. 360)
|
||||
* <li>outHsl[1] is Saturation [0...1]
|
||||
* <li>outHsl[2] is Lightness [0...1]
|
||||
* </ul>
|
||||
*
|
||||
* @param r red component value [0..255]
|
||||
* @param g green component value [0..255]
|
||||
* @param b blue component value [0..255]
|
||||
* @param outHsl 3-element array which holds the resulting HSL components
|
||||
*/
|
||||
public static void RGBToHSL(
|
||||
@IntRange(from = 0x0, to = 0xFF) int r,
|
||||
@IntRange(from = 0x0, to = 0xFF) int g,
|
||||
@IntRange(from = 0x0, to = 0xFF) int b,
|
||||
@NonNull float[] outHsl) {
|
||||
final float rf = r / 255f;
|
||||
final float gf = g / 255f;
|
||||
final float bf = b / 255f;
|
||||
|
||||
final float max = Math.max(rf, Math.max(gf, bf));
|
||||
final float min = Math.min(rf, Math.min(gf, bf));
|
||||
final float deltaMaxMin = max - min;
|
||||
|
||||
float h, s;
|
||||
float l = (max + min) / 2f;
|
||||
|
||||
if (max == min) {
|
||||
// Monochromatic
|
||||
h = s = 0f;
|
||||
} else {
|
||||
if (max == rf) {
|
||||
h = ((gf - bf) / deltaMaxMin) % 6f;
|
||||
} else if (max == gf) {
|
||||
h = ((bf - rf) / deltaMaxMin) + 2f;
|
||||
} else {
|
||||
h = ((rf - gf) / deltaMaxMin) + 4f;
|
||||
}
|
||||
|
||||
s = deltaMaxMin / (1f - Math.abs(2f * l - 1f));
|
||||
}
|
||||
|
||||
h = (h * 60f) % 360f;
|
||||
if (h < 0) {
|
||||
h += 360f;
|
||||
}
|
||||
|
||||
outHsl[0] = constrain(h, 0f, 360f);
|
||||
outHsl[1] = constrain(s, 0f, 1f);
|
||||
outHsl[2] = constrain(l, 0f, 1f);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package code.name.monkey.retromusic.util.theme
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import code.name.monkey.retromusic.R
|
||||
import code.name.monkey.retromusic.extensions.generalThemeValue
|
||||
import code.name.monkey.retromusic.util.PreferenceUtil
|
||||
import code.name.monkey.retromusic.util.theme.ThemeMode.*
|
||||
|
||||
@StyleRes
|
||||
fun Context.getThemeResValue(): Int =
|
||||
if (PreferenceUtil.materialYou) {
|
||||
if (generalThemeValue == BLACK) R.style.Theme_RetroMusic_MD3_Black
|
||||
else R.style.Theme_RetroMusic_MD3
|
||||
} else {
|
||||
when (generalThemeValue) {
|
||||
LIGHT -> R.style.Theme_RetroMusic_Light
|
||||
DARK -> R.style.Theme_RetroMusic_Base
|
||||
BLACK -> R.style.Theme_RetroMusic_Black
|
||||
AUTO -> R.style.Theme_RetroMusic_FollowSystem
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.getNightMode(): Int = when (generalThemeValue) {
|
||||
LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
DARK -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package code.name.monkey.retromusic.util.theme
|
||||
|
||||
enum class ThemeMode {
|
||||
LIGHT,
|
||||
DARK,
|
||||
BLACK,
|
||||
AUTO
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue