package com.simplecity.amp_library;
import android.Manifest;
import android.app.Application;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.os.AsyncTask;
import android.os.Environment;
import android.os.Handler;
import android.os.StrictMode;
import android.preference.PreferenceManager;
import android.provider.MediaStore;
import android.support.multidex.MultiDex;
import android.support.v4.content.ContextCompat;
import android.text.TextUtils;
import android.util.Log;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import com.bumptech.glide.Glide;
import com.crashlytics.android.Crashlytics;
import com.crashlytics.android.answers.Answers;
import com.crashlytics.android.core.CrashlyticsCore;
import com.google.android.libraries.cast.companionlibrary.cast.CastConfiguration;
import com.google.android.libraries.cast.companionlibrary.cast.VideoCastManager;
import com.google.firebase.analytics.FirebaseAnalytics;
import com.simplecity.amp_library.constants.Config;
import com.simplecity.amp_library.model.Genre;
import com.simplecity.amp_library.model.Query;
import com.simplecity.amp_library.model.UserSelectedArtwork;
import com.simplecity.amp_library.services.EqualizerService;
import com.simplecity.amp_library.sql.SqlUtils;
import com.simplecity.amp_library.sql.databases.CustomArtworkTable;
import com.simplecity.amp_library.sql.providers.PlayCountTable;
import com.simplecity.amp_library.sql.sqlbrite.SqlBriteUtils;
import com.simplecity.amp_library.utils.AnalyticsManager;
import com.simplecity.amp_library.utils.SettingsManager;
import com.simplecity.amp_library.utils.ShuttleUtils;
import com.squareup.leakcanary.LeakCanary;
import com.squareup.leakcanary.RefWatcher;
import org.jaudiotagger.tag.TagOptionSingleton;
import java.io.File;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
import io.fabric.sdk.android.Fabric;
import rx.Observable;
import rx.schedulers.Schedulers;
public class ShuttleApplication extends Application {
private static ShuttleApplication sInstance;
public static synchronized ShuttleApplication getInstance() {
return sInstance;
}
private static final String TAG = "ShuttleApplication";
private boolean isUpgraded;
public static final double VOLUME_INCREMENT = 0.05;
private RefWatcher refWatcher;
public HashMap<String, UserSelectedArtwork> userSelectedArtwork = new HashMap<>();
private static Logger jaudioTaggerLogger1 = Logger.getLogger("org.jaudiotagger.audio");
private static Logger jaudioTaggerLogger2 = Logger.getLogger("org.jaudiotagger");
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
if (BuildConfig.MULTIDEX_ENABLED) {
MultiDex.install(base);
}
}
@Override
public void onCreate() {
super.onCreate();
refWatcher = LeakCanary.install(this);
sInstance = this;
if (BuildConfig.DEBUG) {
Log.w(TAG, "**Debug mode is ON**");
enableStrictMode();
}
//Crashlytics
CrashlyticsCore core = new CrashlyticsCore.Builder().disabled(BuildConfig.DEBUG).build();
Fabric.with(this, new Crashlytics.Builder().core(core).answers(new Answers()).build(), new Crashlytics());
//Firebase Analytics
FirebaseAnalytics.getInstance(this);
//Possible fix for ClipboardUIManager leak.
//See https://gist.github.com/pepyakin/8d2221501fd572d4a61c and
//https://github.com/square/leakcanary/blob/master/leakcanary-android/src/main/java/com/squareup/leakcanary/AndroidExcludedRefs.java
try {
Class<?> cls = Class.forName("android.sec.clipboard.ClipboardUIManager");
Method m = cls.getDeclaredMethod("getInstance", Context.class);
Object o = m.invoke(null, this);
} catch (Exception ignored) {
}
VideoCastManager.initialize(this,
new CastConfiguration.Builder(Config.CHROMECAST_APP_ID)
.enableLockScreen()
.enableNotification()
.build()
);
final SharedPreferences mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
setIsUpgraded(mPrefs.getBoolean("pref_theme_gold", false));
// we cannot call setDefaultValues for multiple fragment based XML preference
// files with readAgain flag set to false, so always check KEY_HAS_SET_DEFAULT_VALUES
if (!mPrefs.getBoolean(PreferenceManager.KEY_HAS_SET_DEFAULT_VALUES, false)) {
PreferenceManager.setDefaultValues(this, R.xml.settings_artwork, true);
PreferenceManager.setDefaultValues(this, R.xml.settings_blacklist, true);
PreferenceManager.setDefaultValues(this, R.xml.settings_display, true);
PreferenceManager.setDefaultValues(this, R.xml.settings_headers, true);
PreferenceManager.setDefaultValues(this, R.xml.settings_headset, true);
PreferenceManager.setDefaultValues(this, R.xml.settings_scrobbling, true);
PreferenceManager.setDefaultValues(this, R.xml.settings_themes, true);
}
// Turn off logging for jaudiotagger.
jaudioTaggerLogger1.setLevel(Level.OFF);
jaudioTaggerLogger2.setLevel(Level.OFF);
TagOptionSingleton.getInstance().setPadNumbers(true);
SettingsManager.getInstance().incrementLaunchCount();
startService(new Intent(this, EqualizerService.class));
Observable.fromCallable(() -> {
Query query = new Query.Builder()
.uri(CustomArtworkTable.URI)
.projection(new String[]{CustomArtworkTable.COLUMN_ID, CustomArtworkTable.COLUMN_KEY, CustomArtworkTable.COLUMN_TYPE, CustomArtworkTable.COLUMN_PATH})
.build();
SqlUtils.createActionableQuery(ShuttleApplication.this, cursor ->
userSelectedArtwork.put(
cursor.getString(cursor.getColumnIndexOrThrow(CustomArtworkTable.COLUMN_KEY)),
new UserSelectedArtwork(
cursor.getInt(cursor.getColumnIndexOrThrow(CustomArtworkTable.COLUMN_TYPE)),
cursor.getString(cursor.getColumnIndexOrThrow(CustomArtworkTable.COLUMN_PATH)))
),
query);
return null;
})
.subscribeOn(Schedulers.io())
.subscribe();
//Clean up the genres database - remove any genres which contain no songs. Also, populates song counts.
cleanGenres();
//Clean up the 'most played' playlist. We delay this call to allow the app to finish launching,
//since it's not time critical.
new Handler().postDelayed(this::cleanMostPlayedPlaylist, 5000);
//Clean up any old, unused resources.
new Handler().postDelayed(this::deleteOldResources, 10000);
}
public RefWatcher getRefWatcher() {
return this.refWatcher;
}
@Override
public void onLowMemory() {
super.onLowMemory();
Glide.get(this).clearMemory();
}
public static String getVersion() {
try {
return sInstance.getPackageManager().getPackageInfo(sInstance.getPackageName(), 0).versionName;
} catch (PackageManager.NameNotFoundException | NullPointerException ignored) {
}
return "unknown";
}
public void setIsUpgraded(boolean isUpgraded) {
this.isUpgraded = isUpgraded;
AnalyticsManager.setIsUpgraded();
}
public boolean getIsUpgraded() {
return isUpgraded || BuildConfig.DEBUG;
}
private void deleteOldResources() {
ShuttleUtils.execute(new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
//Delete albumthumbs/artists directory
if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
File file = new File(Environment.getExternalStorageDirectory() + "/albumthumbs/artists/");
if (file.exists() && file.isDirectory()) {
File[] files = file.listFiles();
if (files != null) {
for (File child : files) {
child.delete();
}
}
file.delete();
}
}
//Delete old http cache
File oldHttpCache = getDiskCacheDir("http");
if (oldHttpCache != null && oldHttpCache.exists()) {
oldHttpCache.delete();
}
//Delete old thumbs cache
File oldThumbsCache = getDiskCacheDir("thumbs");
if (oldThumbsCache != null && oldThumbsCache.exists()) {
oldThumbsCache.delete();
}
return null;
}
});
}
public static File getDiskCacheDir(String uniqueName) {
try {
// Check if media is mounted or storage is built-in, if so, try and use external cache dir
// otherwise use internal cache dir
String cachePath = null;
File externalCacheDir = getInstance().getExternalCacheDir();
if ((Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) || !Environment.isExternalStorageRemovable()) && externalCacheDir != null) {
cachePath = externalCacheDir.getPath();
} else if (getInstance().getCacheDir() != null) {
cachePath = getInstance().getCacheDir().getPath();
}
if (cachePath != null) {
return new File(cachePath + File.separator + uniqueName);
}
} catch (RuntimeException e) {
Log.e(TAG, "getDiskCacheDir() failed. " + e.toString());
}
return null;
}
/**
* Check items in the Most Played playlist and ensure their ids exist in the MediaStore.
* <p>
* If they don't, remove them from the playlist.
*/
private void cleanMostPlayedPlaylist() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
return;
}
Observable.fromCallable(() -> {
List<Integer> playCountIds = new ArrayList<>();
Query query = new Query.Builder()
.uri(PlayCountTable.URI)
.projection(new String[]{PlayCountTable.COLUMN_ID})
.build();
SqlUtils.createActionableQuery(this, cursor ->
playCountIds.add(cursor.getInt(cursor.getColumnIndex(PlayCountTable.COLUMN_ID))), query);
List<Integer> songIds = new ArrayList<>();
query = new Query.Builder()
.uri(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI)
.projection(new String[]{MediaStore.Audio.Media._ID})
.build();
SqlUtils.createActionableQuery(this, cursor ->
songIds.add(cursor.getInt(cursor.getColumnIndex(PlayCountTable.COLUMN_ID))), query);
StringBuilder selection = new StringBuilder(PlayCountTable.COLUMN_ID + " IN (");
selection.append(TextUtils.join(",", Stream.of(playCountIds)
.filter(playCountId -> !songIds.contains(playCountId))
.collect(Collectors.toList())));
selection.append(")");
try {
getContentResolver().delete(PlayCountTable.URI, selection.toString(), null);
} catch (IllegalArgumentException ignored) {
}
return null;
})
.subscribeOn(Schedulers.io())
.subscribe();
}
private void cleanGenres() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
return;
}
// This observable emits a genre every 250ms. We then make a query against the genre database to populate the song count.
// If the count is zero, then the genre can be deleted.
// The reason for the delay is, on some slower devices, if the user has tons of genres, a ton of cursors get created.
// If the maximum number of cursors is created (based on memory/processor speed or god knows what else), then the device
// will start throwing CursorWindow exceptions, and the queries will slow down massively. This ends up making all queries slow.
// This task isn't time critical, so we can afford to let it just casually do its job.
SqlBriteUtils.createQuery(ShuttleApplication.getInstance(), Genre::new, Genre.getQuery())
.first()
.flatMap(Observable::from)
.concatMap(genre -> Observable.just(genre).delay(250, TimeUnit.MILLISECONDS))
.flatMap(genre -> genre.getSongCountObservable(ShuttleApplication.getInstance())
.doOnNext(numSongs -> {
if (numSongs == 0) {
try {
getContentResolver().delete(MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI, MediaStore.Audio.Genres._ID + " == " + genre.id, null);
} catch (IllegalArgumentException | UnsupportedOperationException ignored) {
//Don't care if we couldn't delete this uri.
}
}
})
)
// Since this is called on app launch, let's delay to allow more important tasks to complete.
.delaySubscription(2500, TimeUnit.MILLISECONDS)
.subscribe();
}
private void enableStrictMode() {
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
.detectAll()
.penaltyLog()
.build());
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.penaltyFlashScreen()
.build());
}
}