├── .gitignore ├── .idea ├── caches │ └── build_file_checksums.ser ├── codeStyles │ └── Project.xml ├── misc.xml ├── modules.xml ├── runConfigurations.xml └── vcs.xml ├── LICENSE ├── README.md ├── app ├── build.gradle ├── proguard-rules.pro ├── release │ └── app.aab └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── tanuj │ │ └── nowplayinghistory │ │ ├── App.java │ │ ├── MapItem.java │ │ ├── NotificationListener.java │ │ ├── SwipeAction.java │ │ ├── Utils.java │ │ ├── activities │ │ ├── IntroActivity.java │ │ ├── MainActivity.java │ │ ├── MapActivity.java │ │ └── SplashActivity.java │ │ ├── adapters │ │ ├── SongsListAdapter.java │ │ └── SongsPagedListAdapter.java │ │ ├── behaviors │ │ ├── CustomFloatingActionButtonBehavior.java │ │ └── RateMeViewBehavior.java │ │ ├── callbacks │ │ ├── FavoritesItemTouchCallback.java │ │ └── RecentsItemTouchCallback.java │ │ ├── fragments │ │ ├── ClusterDialogFragment.java │ │ ├── CustomPolicySlide.java │ │ ├── EmptyFragment.java │ │ ├── ListFragment.java │ │ └── MapFragment.java │ │ ├── lastfm │ │ ├── LastFmGlideModule.java │ │ ├── LastFmService.java │ │ ├── LastFmSongFetcher.java │ │ ├── LastFmSongLoader.java │ │ └── pojos │ │ │ ├── Album.java │ │ │ ├── Artist.java │ │ │ ├── Attr.java │ │ │ ├── Example.java │ │ │ ├── Image.java │ │ │ ├── Streamable.java │ │ │ ├── Tag.java │ │ │ ├── Toptags.java │ │ │ ├── Track.java │ │ │ ├── TrackInfo.java │ │ │ └── Wiki.java │ │ ├── persistence │ │ ├── AppDatabase.java │ │ ├── FavSong.java │ │ ├── Song.java │ │ └── SongDao.java │ │ ├── tasks │ │ └── InitClusterManagerTask.java │ │ ├── viewmodels │ │ └── SongsListViewModel.java │ │ └── views │ │ └── RateMeView.java │ ├── promo-material │ ├── feature graphic │ │ ├── banner.png │ │ └── banner.pptx │ ├── ic_launcher-web.png │ └── screenshots │ │ ├── Screenshot_20180708-214244.png │ │ ├── Screenshot_20180708-215514.png │ │ ├── Screenshot_20180726-212808.png │ │ ├── Screenshot_20180726-213151.png │ │ ├── Screenshot_20180728-182753.png │ │ └── Screenshot_20180728-182805.png │ └── res │ ├── drawable │ ├── empty_image.xml │ ├── ic_album.xml │ ├── ic_delete.xml │ ├── ic_favorites.xml │ ├── ic_filter.xml │ ├── ic_list.xml │ ├── ic_map.xml │ ├── ic_recent.xml │ ├── slide_1.png │ ├── slide_2.png │ ├── slide_3.png │ ├── slide_4.png │ └── splash_screen_background.xml │ ├── layout-land │ ├── cluster_dialog.xml │ └── empty_state.xml │ ├── layout │ ├── activity_main.xml │ ├── activity_map.xml │ ├── cluster_dialog.xml │ ├── empty_state.xml │ ├── list_songs.xml │ ├── permissions.xml │ ├── rate_me.xml │ └── song_info.xml │ ├── menu │ ├── bottom_nav.xml │ └── filter_songs.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ └── ic_launcher_foreground.png │ ├── mipmap-mdpi │ └── ic_launcher_foreground.png │ ├── mipmap-xhdpi │ └── ic_launcher_foreground.png │ ├── mipmap-xxhdpi │ └── ic_launcher_foreground.png │ ├── mipmap-xxxhdpi │ └── ic_launcher_foreground.png │ ├── raw │ └── map_night_theme.json │ ├── values-af │ └── strings.xml │ ├── values-am │ └── strings.xml │ ├── values-ar │ └── strings.xml │ ├── values-az │ └── strings.xml │ ├── values-b+es+419 │ └── strings.xml │ ├── values-b+sr+Latn │ └── strings.xml │ ├── values-be │ └── strings.xml │ ├── values-bg │ └── strings.xml │ ├── values-bn │ └── strings.xml │ ├── values-bs │ └── strings.xml │ ├── values-ca │ └── strings.xml │ ├── values-cs │ └── strings.xml │ ├── values-da │ └── strings.xml │ ├── values-de-rAT │ └── strings.xml │ ├── values-de-rCH │ └── strings.xml │ ├── values-de │ └── strings.xml │ ├── values-el │ └── strings.xml │ ├── values-en-rAU │ └── strings.xml │ ├── values-en-rCA │ └── strings.xml │ ├── values-en-rGB │ └── strings.xml │ ├── values-en-rIE │ └── strings.xml │ ├── values-en-rIN │ └── strings.xml │ ├── values-en-rSG │ └── strings.xml │ ├── values-en-rZA │ └── strings.xml │ ├── values-es-rAR │ └── strings.xml │ ├── values-es-rBO │ └── strings.xml │ ├── values-es-rCL │ └── strings.xml │ ├── values-es-rCO │ └── strings.xml │ ├── values-es-rCR │ └── strings.xml │ ├── values-es-rDO │ └── strings.xml │ ├── values-es-rEC │ └── strings.xml │ ├── values-es-rGT │ └── strings.xml │ ├── values-es-rHN │ └── strings.xml │ ├── values-es-rMX │ └── strings.xml │ ├── values-es-rNI │ └── strings.xml │ ├── values-es-rPA │ └── strings.xml │ ├── values-es-rPE │ └── strings.xml │ ├── values-es-rPR │ └── strings.xml │ ├── values-es-rPY │ └── strings.xml │ ├── values-es-rSV │ └── strings.xml │ ├── values-es-rUS │ └── strings.xml │ ├── values-es-rUY │ └── strings.xml │ ├── values-es-rVE │ └── strings.xml │ ├── values-es │ └── strings.xml │ ├── values-et │ └── strings.xml │ ├── values-eu │ └── strings.xml │ ├── values-fa │ └── strings.xml │ ├── values-fi │ └── strings.xml │ ├── values-fil │ └── strings.xml │ ├── values-fr-rCA │ └── strings.xml │ ├── values-fr-rCH │ └── strings.xml │ ├── values-fr │ └── strings.xml │ ├── values-gl │ └── strings.xml │ ├── values-gsw │ └── strings.xml │ ├── values-gu │ └── strings.xml │ ├── values-he │ └── strings.xml │ ├── values-hi │ └── strings.xml │ ├── values-hr │ └── strings.xml │ ├── values-hu │ └── strings.xml │ ├── values-hy │ └── strings.xml │ ├── values-id │ └── strings.xml │ ├── values-in │ └── strings.xml │ ├── values-is │ └── strings.xml │ ├── values-it │ └── strings.xml │ ├── values-iw │ └── strings.xml │ ├── values-ja │ └── strings.xml │ ├── values-ka │ └── strings.xml │ ├── values-kk │ └── strings.xml │ ├── values-km │ └── strings.xml │ ├── values-kn │ └── strings.xml │ ├── values-ko │ └── strings.xml │ ├── values-ky │ └── strings.xml │ ├── values-ln │ └── strings.xml │ ├── values-lo │ └── strings.xml │ ├── values-lt │ └── strings.xml │ ├── values-lv │ └── strings.xml │ ├── values-mk │ └── strings.xml │ ├── values-ml │ └── strings.xml │ ├── values-mn │ └── strings.xml │ ├── values-mo │ └── strings.xml │ ├── values-mr │ └── strings.xml │ ├── values-ms │ └── strings.xml │ ├── values-my │ └── strings.xml │ ├── values-nb │ └── strings.xml │ ├── values-ne │ └── strings.xml │ ├── values-night │ └── styles.xml │ ├── values-nl │ └── strings.xml │ ├── values-no │ └── strings.xml │ ├── values-pa │ └── strings.xml │ ├── values-pl │ └── strings.xml │ ├── values-pt-rBR │ └── strings.xml │ ├── values-pt-rPT │ └── strings.xml │ ├── values-pt │ └── strings.xml │ ├── values-ro │ └── strings.xml │ ├── values-ru │ └── strings.xml │ ├── values-si │ └── strings.xml │ ├── values-sk │ └── strings.xml │ ├── values-sl │ └── strings.xml │ ├── values-sq │ └── strings.xml │ ├── values-sr │ └── strings.xml │ ├── values-sv │ └── strings.xml │ ├── values-sw │ └── strings.xml │ ├── values-ta │ └── strings.xml │ ├── values-te │ └── strings.xml │ ├── values-th │ └── strings.xml │ ├── values-tl │ └── strings.xml │ ├── values-tr │ └── strings.xml │ ├── values-uk │ └── strings.xml │ ├── values-ur │ └── strings.xml │ ├── values-uz │ └── strings.xml │ ├── values-vi │ └── strings.xml │ ├── values-zh-rCN │ └── strings.xml │ ├── values-zh-rHK │ └── strings.xml │ ├── values-zh-rTW │ └── strings.xml │ ├── values-zh │ └── strings.xml │ ├── values-zu │ └── strings.xml │ └── values │ ├── colors.xml │ ├── ic_launcher_background.xml │ ├── strings.xml │ └── styles.xml ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── script.py └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.ap_ 4 | 5 | # Files for the ART/Dalvik VM 6 | *.dex 7 | 8 | # Java class files 9 | *.class 10 | 11 | # Generated files 12 | bin/ 13 | gen/ 14 | out/ 15 | 16 | # Gradle files 17 | .gradle/ 18 | build/ 19 | 20 | # Local configuration file (sdk path, etc) 21 | local.properties 22 | 23 | # Proguard folder generated by Eclipse 24 | proguard/ 25 | 26 | # Log Files 27 | *.log 28 | 29 | # Android Studio Navigation editor temp files 30 | .navigation/ 31 | 32 | # Android Studio captures folder 33 | captures/ 34 | 35 | # Intellij 36 | *.iml 37 | .idea/workspace.xml 38 | .idea/tasks.xml 39 | .idea/gradle.xml 40 | .idea/dictionaries 41 | .idea/libraries 42 | .idea/caches/ 43 | 44 | # Keystore files 45 | *.jks 46 | 47 | # External native build folder generated in Android Studio 2.2 and later 48 | .externalNativeBuild 49 | 50 | # Google Services (e.g. APIs or Firebase) 51 | google-services.json 52 | 53 | # Freeline 54 | freeline.py 55 | freeline/ 56 | freeline_project_description.json 57 | 58 | # misc 59 | gradle.properties 60 | -------------------------------------------------------------------------------- /.idea/caches/build_file_checksums.ser: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitanuj/nowplayinghistory/2232ac2a0272a30ef02f5096a78906d9b9897e59/.idea/caches/build_file_checksums.ser -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 16 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 31 | 32 | 33 | 34 | 35 | 36 | 38 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Tanuj Mittal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Now Playing History for Pixel 2 2 | 3 | 4 | Get it on Google Play 5 | 6 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | maven { url 'https://maven.fabric.io/public' } 4 | } 5 | 6 | dependencies { 7 | classpath 'io.fabric.tools:gradle:1.+' 8 | } 9 | } 10 | apply plugin: 'com.android.application' 11 | apply plugin: 'io.fabric' 12 | 13 | repositories { 14 | maven { url 'https://maven.fabric.io/public' } 15 | } 16 | 17 | android { 18 | compileSdkVersion 28 19 | defaultConfig { 20 | applicationId "com.tanuj.nowplayinghistory" 21 | minSdkVersion 26 22 | targetSdkVersion 28 23 | versionCode 12 24 | versionName "1.5" 25 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 26 | resValue "string", "google_maps_key", (project.findProperty("GOOGLE_MAPS_API_KEY") ?: "") 27 | resValue "string", "lastfm_key", (project.findProperty("LASTFM_API_KEY") ?: "") 28 | } 29 | buildTypes { 30 | release { 31 | minifyEnabled false 32 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 33 | } 34 | } 35 | compileOptions { 36 | targetCompatibility 1.8 37 | sourceCompatibility 1.8 38 | } 39 | dataBinding { 40 | enabled = true 41 | } 42 | lintOptions { 43 | checkReleaseBuilds false 44 | // Or, if you prefer, you can continue to check for errors in release builds, 45 | // but continue the build even when errors are found: 46 | abortOnError false 47 | } 48 | } 49 | 50 | dependencies { 51 | implementation "androidx.appcompat:appcompat:1.0.2" 52 | implementation "androidx.recyclerview:recyclerview:1.0.0" 53 | implementation "com.google.android.material:material:1.0.0" 54 | implementation "androidx.lifecycle:lifecycle-extensions:2.0.0" 55 | annotationProcessor "androidx.lifecycle:lifecycle-compiler:2.0.0" 56 | implementation "androidx.lifecycle:lifecycle-common-java8:2.0.0" 57 | implementation "androidx.room:room-runtime:2.1.0-alpha04" 58 | annotationProcessor "androidx.room:room-compiler:2.1.0-alpha04" 59 | implementation "androidx.paging:paging-runtime:2.1.0" 60 | implementation "com.google.android.gms:play-services-maps:16.1.0" 61 | implementation "com.google.android.gms:play-services-location:16.0.0" 62 | implementation "com.google.maps.android:android-maps-utils:0.5" 63 | implementation 'com.github.AppIntro:appintro:5.1.0' 64 | implementation('com.crashlytics.sdk.android:crashlytics:2.9.9@aar') { 65 | transitive = true 66 | } 67 | implementation 'com.github.bumptech.glide:glide:4.9.0' 68 | annotationProcessor 'com.github.bumptech.glide:compiler:4.9.0' 69 | implementation 'com.squareup.retrofit2:retrofit:2.5.0' 70 | implementation 'com.squareup.moshi:moshi:1.8.0' 71 | implementation 'com.squareup.retrofit2:converter-moshi:2.5.0' 72 | implementation 'com.squareup.okhttp3:okhttp:3.13.1' 73 | } 74 | 75 | apply plugin: 'com.google.gms.google-services' 76 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/release/app.aab: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitanuj/nowplayinghistory/2232ac2a0272a30ef02f5096a78906d9b9897e59/app/release/app.aab -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 16 | 17 | 20 | 21 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 34 | 35 | 36 | 37 | 38 | 39 | 42 | 43 | 46 | 47 | 50 | 51 | 54 | 57 | 58 | 61 | 62 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/App.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory; 2 | 3 | import android.app.Application; 4 | import android.content.Context; 5 | 6 | import com.crashlytics.android.Crashlytics; 7 | import com.tanuj.nowplayinghistory.lastfm.GlideApp; 8 | import com.tanuj.nowplayinghistory.lastfm.LastFmService; 9 | import com.tanuj.nowplayinghistory.persistence.AppDatabase; 10 | 11 | import androidx.appcompat.app.AppCompatDelegate; 12 | import androidx.room.Room; 13 | import io.fabric.sdk.android.Fabric; 14 | import retrofit2.Retrofit; 15 | import retrofit2.converter.moshi.MoshiConverterFactory; 16 | 17 | import static androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_AUTO; 18 | 19 | public class App extends Application { 20 | 21 | static { 22 | AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_AUTO); 23 | } 24 | 25 | private static Context CONTEXT; 26 | private static AppDatabase DB; 27 | private static LastFmService LAST_FM_SERVICE; 28 | 29 | @Override 30 | public void onCreate() { 31 | super.onCreate(); 32 | Fabric.with(this, new Crashlytics()); 33 | 34 | CONTEXT = getApplicationContext(); 35 | DB = Room.databaseBuilder(getApplicationContext(), AppDatabase.class, "app-db").fallbackToDestructiveMigration().build(); 36 | 37 | Retrofit retrofit = new Retrofit.Builder() 38 | .baseUrl("https://ws.audioscrobbler.com") 39 | .addConverterFactory(MoshiConverterFactory.create()) 40 | .build(); 41 | LAST_FM_SERVICE = retrofit.create(LastFmService.class); 42 | } 43 | 44 | public static Context getContext() { 45 | return CONTEXT; 46 | } 47 | 48 | public static AppDatabase getDb() { 49 | return DB; 50 | } 51 | 52 | public static LastFmService getLastFmService() { 53 | return LAST_FM_SERVICE; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/MapItem.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory; 2 | 3 | import android.os.Parcel; 4 | import android.os.Parcelable; 5 | 6 | import com.google.android.gms.maps.model.LatLng; 7 | import com.google.maps.android.clustering.ClusterItem; 8 | import com.tanuj.nowplayinghistory.persistence.Song; 9 | 10 | public class MapItem implements ClusterItem, Parcelable { 11 | 12 | private Song song; 13 | 14 | public MapItem(Song song) { 15 | this.song = song; 16 | } 17 | 18 | protected MapItem(Parcel in) { 19 | song = in.readParcelable(Song.class.getClassLoader()); 20 | } 21 | 22 | public static final Creator CREATOR = new Creator() { 23 | @Override 24 | public MapItem createFromParcel(Parcel in) { 25 | return new MapItem(in); 26 | } 27 | 28 | @Override 29 | public MapItem[] newArray(int size) { 30 | return new MapItem[size]; 31 | } 32 | }; 33 | 34 | @Override 35 | public LatLng getPosition() { 36 | return new LatLng(song.getLat(), song.getLon()); 37 | } 38 | 39 | @Override 40 | public String getTitle() { 41 | return song.getSongText(); 42 | } 43 | 44 | @Override 45 | public String getSnippet() { 46 | return Utils.getTimestampString(song.getTimestamp()); 47 | } 48 | 49 | public Song getSong() { 50 | return song; 51 | } 52 | 53 | @Override 54 | public int describeContents() { 55 | return 0; 56 | } 57 | 58 | @Override 59 | public void writeToParcel(Parcel dest, int flags) { 60 | dest.writeParcelable(song, flags); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/NotificationListener.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory; 2 | 3 | import android.annotation.SuppressLint; 4 | import android.app.Notification; 5 | import android.location.Location; 6 | import android.service.notification.NotificationListenerService; 7 | import android.service.notification.StatusBarNotification; 8 | import android.text.TextUtils; 9 | 10 | import com.tanuj.nowplayinghistory.persistence.Song; 11 | 12 | public class NotificationListener extends NotificationListenerService { 13 | 14 | private static final String GOOGLE_MUSIC_SERVICE_PKG_NAME = "com.google.intelligence.sense"; 15 | private static final String GOOGLE_MUSIC_SERVICE_CHANNEL_NAME = "com.google.intelligence.sense.ambientmusic.MusicNotificationChannel"; 16 | 17 | @Override 18 | public void onListenerConnected() { 19 | StatusBarNotification[] notifications = getActiveNotifications(); 20 | if (notifications != null) { 21 | process(notifications); 22 | } 23 | } 24 | 25 | @Override 26 | public void onNotificationPosted(StatusBarNotification sbn) { 27 | if (sbn != null) { 28 | process(sbn); 29 | } 30 | } 31 | 32 | private void process(StatusBarNotification... statusBarNotifications) { 33 | for (StatusBarNotification statusBarNotification : statusBarNotifications) { 34 | if (statusBarNotification.getPackageName().equals(GOOGLE_MUSIC_SERVICE_PKG_NAME)) { 35 | Notification notification = statusBarNotification.getNotification(); 36 | if (notification.getChannelId().equals(GOOGLE_MUSIC_SERVICE_CHANNEL_NAME)) { 37 | long timeMillis = notification.when; 38 | String songText = notification.extras.getString(Notification.EXTRA_TITLE); 39 | if (!TextUtils.isEmpty(songText)) { 40 | persistSongAsync(timeMillis, songText); 41 | } 42 | } 43 | } 44 | } 45 | } 46 | 47 | @SuppressLint("StaticFieldLeak") 48 | private void persistSongAsync(final long timeMillis, final String songText) { 49 | Utils.executeAsync(() -> { 50 | Song song = new Song(timeMillis, songText); 51 | Song latestSong = App.getDb().songDao().loadLatestSong(); 52 | 53 | if (!song.equals(latestSong)) { 54 | Location location = Utils.getCurrentLocation(); 55 | if (location != null) { 56 | song.setLat(location.getLatitude()); 57 | song.setLon(location.getLongitude()); 58 | } 59 | App.getDb().songDao().insert(song); 60 | } 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/SwipeAction.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory; 2 | 3 | import android.graphics.Canvas; 4 | import android.graphics.Color; 5 | import android.graphics.Paint; 6 | import android.graphics.Rect; 7 | import android.graphics.Typeface; 8 | import android.text.TextPaint; 9 | import android.view.View; 10 | 11 | public class SwipeAction { 12 | 13 | public enum Dir { 14 | LEFT, 15 | RIGHT 16 | } 17 | 18 | private static final float ACTION_TEXT_PADDING = Utils.dpToPx(16); 19 | private static final float ACTION_TEXT_SIZE = Utils.dpToPx(13); 20 | 21 | private Dir swipeDir; 22 | private String actionText; 23 | 24 | private Paint backgroundPaint = new Paint(); 25 | private Paint textPaint = new TextPaint(); 26 | private Rect textBounds = new Rect(); 27 | 28 | public SwipeAction(Dir swipeDir, String actionText) { 29 | this.swipeDir = swipeDir; 30 | this.actionText = actionText; 31 | init(); 32 | } 33 | 34 | private void init() { 35 | textPaint.setTextSize(ACTION_TEXT_SIZE); 36 | textPaint.setColor(Color.WHITE); 37 | textPaint.setTypeface(Typeface.DEFAULT_BOLD); 38 | textPaint.getTextBounds(actionText, 0, actionText.length(), textBounds); 39 | 40 | if (swipeDir == Dir.LEFT) { 41 | textPaint.setTextAlign(Paint.Align.LEFT); 42 | backgroundPaint.setColor(Color.RED); 43 | } else if (swipeDir == Dir.RIGHT) { 44 | textPaint.setTextAlign(Paint.Align.RIGHT); 45 | backgroundPaint.setColor(Color.GREEN); 46 | } 47 | } 48 | 49 | public void draw(Canvas c, View itemView, float dX) { 50 | if (swipeDir == Dir.LEFT) { 51 | drawLeftSwipeAction(c, itemView, dX); 52 | } else if (swipeDir == Dir.RIGHT) { 53 | drawRightSwipeAction(c, itemView, dX); 54 | } 55 | } 56 | 57 | private void drawLeftSwipeAction(Canvas c, View itemView, float dX) { 58 | float x = itemView.getRight() + dX + ACTION_TEXT_PADDING; 59 | float y = itemView.getTop() + itemView.getHeight() / 2 - textBounds.exactCenterY(); 60 | c.drawRect(itemView.getRight() + dX, itemView.getTop(), itemView.getRight(), itemView.getBottom(), backgroundPaint); 61 | c.drawText(actionText, x, y, textPaint); 62 | } 63 | 64 | private void drawRightSwipeAction(Canvas c, View itemView, float dX) { 65 | float x = itemView.getLeft() + dX - ACTION_TEXT_PADDING; 66 | float y = itemView.getTop() + itemView.getHeight() / 2 - textBounds.exactCenterY(); 67 | c.drawRect(itemView.getLeft(), itemView.getTop(), itemView.getLeft() + dX, itemView.getBottom(), backgroundPaint); 68 | c.drawText(actionText, x, y, textPaint); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/Utils.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory; 2 | 3 | import android.Manifest; 4 | import android.annotation.SuppressLint; 5 | import android.app.SearchManager; 6 | import android.content.ComponentName; 7 | import android.content.Intent; 8 | import android.content.SharedPreferences; 9 | import android.content.pm.PackageManager; 10 | import android.content.res.Configuration; 11 | import android.content.res.Resources; 12 | import android.location.Location; 13 | import android.net.Uri; 14 | import android.os.AsyncTask; 15 | import android.os.Build; 16 | import android.preference.PreferenceManager; 17 | import android.provider.MediaStore; 18 | import android.provider.Settings; 19 | import android.text.TextUtils; 20 | import android.text.format.DateUtils; 21 | import android.util.TypedValue; 22 | import android.widget.Toast; 23 | 24 | import com.google.android.gms.location.LocationServices; 25 | import com.google.android.gms.maps.GoogleMap; 26 | import com.google.android.gms.maps.model.MapStyleOptions; 27 | import com.google.android.gms.tasks.Task; 28 | import com.google.android.gms.tasks.Tasks; 29 | import com.tanuj.nowplayinghistory.persistence.Song; 30 | 31 | import java.util.Collection; 32 | 33 | import androidx.core.content.ContextCompat; 34 | 35 | import static android.content.res.Configuration.ORIENTATION_PORTRAIT; 36 | 37 | public class Utils { 38 | 39 | private static final String ENABLED_NOTIFICATION_LISTENERS = "enabled_notification_listeners"; 40 | private static final String SONG_STRING_QUALIFIER = "%1$s"; 41 | private static final String ARTIST_STRING_QUALIFIER = "%2$s"; 42 | 43 | @SuppressLint("StaticFieldLeak") 44 | public static void executeAsync(final Runnable doInBackgroundRunnable) { 45 | new AsyncTask() { 46 | @Override 47 | protected Void doInBackground(Void... voids) { 48 | doInBackgroundRunnable.run(); 49 | return null; 50 | } 51 | }.execute(); 52 | } 53 | 54 | @SuppressLint("StaticFieldLeak") 55 | public static void executeAsync(final Runnable doInBackgroundRunnable, final Runnable onPostExecuteRunnable) { 56 | new AsyncTask() { 57 | @Override 58 | protected Void doInBackground(Void... voids) { 59 | doInBackgroundRunnable.run(); 60 | return null; 61 | } 62 | 63 | @Override 64 | protected void onPostExecute(Void aVoid) { 65 | onPostExecuteRunnable.run(); 66 | } 67 | }.execute(); 68 | } 69 | 70 | public static String extractSongTitleFromText(String text) { 71 | try { 72 | String songFormatText = App.getContext().getString(R.string.song_format_string); 73 | String beforeText = songFormatText.substring(0, songFormatText.indexOf(SONG_STRING_QUALIFIER)); 74 | String afterText = songFormatText.substring(songFormatText.indexOf(SONG_STRING_QUALIFIER) + SONG_STRING_QUALIFIER.length()); 75 | if (beforeText.contains(ARTIST_STRING_QUALIFIER)) { 76 | beforeText = beforeText.substring(beforeText.indexOf(ARTIST_STRING_QUALIFIER) + ARTIST_STRING_QUALIFIER.length()); 77 | } 78 | if (afterText.contains(ARTIST_STRING_QUALIFIER)) { 79 | afterText = afterText.substring(0, afterText.indexOf(ARTIST_STRING_QUALIFIER)); 80 | } 81 | 82 | int fromIndex = text.indexOf(beforeText) + beforeText.length(); 83 | if (afterText.length() == 0) { 84 | return text.substring(fromIndex); 85 | } 86 | int toIndex = text.indexOf(afterText); 87 | return text.substring(fromIndex, toIndex); 88 | } catch (Exception e) { 89 | return null; 90 | } 91 | } 92 | 93 | public static String extractArtistTitleFromText(String text) { 94 | try { 95 | String songFormatText = App.getContext().getString(R.string.song_format_string); 96 | String beforeText = songFormatText.substring(0, songFormatText.indexOf(ARTIST_STRING_QUALIFIER)); 97 | String afterText = songFormatText.substring(songFormatText.indexOf(ARTIST_STRING_QUALIFIER) + ARTIST_STRING_QUALIFIER.length()); 98 | if (beforeText.contains(SONG_STRING_QUALIFIER)) { 99 | beforeText = beforeText.substring(beforeText.indexOf(SONG_STRING_QUALIFIER) + SONG_STRING_QUALIFIER.length()); 100 | } 101 | if (afterText.contains(SONG_STRING_QUALIFIER)) { 102 | afterText = afterText.substring(0, afterText.indexOf(SONG_STRING_QUALIFIER)); 103 | } 104 | 105 | int fromIndex = text.indexOf(beforeText) + beforeText.length(); 106 | if (afterText.length() == 0) { 107 | return text.substring(fromIndex); 108 | } 109 | int toIndex = text.indexOf(afterText); 110 | return text.substring(fromIndex, toIndex); 111 | } catch (Exception e) { 112 | return null; 113 | } 114 | } 115 | 116 | public static float dpToPx(float dp) { 117 | Resources r = App.getContext().getResources(); 118 | return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, r.getDisplayMetrics()); 119 | } 120 | 121 | public static void LaunchNotificationAccessActivity() { 122 | Intent intent = new Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS"); 123 | intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 124 | App.getContext().startActivity(intent); 125 | } 126 | 127 | public static boolean isNotificationAccessGranted() { 128 | String pkgName = App.getContext().getPackageName(); 129 | final String flat = Settings.Secure.getString(App.getContext().getContentResolver(), ENABLED_NOTIFICATION_LISTENERS); 130 | if (!TextUtils.isEmpty(flat)) { 131 | final String[] names = flat.split(":"); 132 | for (String name : names) { 133 | final ComponentName cn = ComponentName.unflattenFromString(name); 134 | if (cn != null) { 135 | if (TextUtils.equals(pkgName, cn.getPackageName())) { 136 | return true; 137 | } 138 | } 139 | } 140 | } 141 | return false; 142 | } 143 | 144 | public static boolean isIntroRequired() { 145 | SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(App.getContext()); 146 | return sharedPreferences.getBoolean("isIntroRequired", true); 147 | } 148 | 149 | public static void setIsIntroRequired(boolean isIntroRequired) { 150 | SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(App.getContext()); 151 | SharedPreferences.Editor editor = sharedPreferences.edit(); 152 | editor.putBoolean("isIntroRequired", isIntroRequired); 153 | editor.apply(); 154 | } 155 | 156 | public static void launchMusicApp(String songText) { 157 | Intent intent = new Intent(MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH); 158 | intent.putExtra(MediaStore.EXTRA_MEDIA_FOCUS, "vnd.android.cursor.item/audio"); 159 | 160 | String songTitle = Utils.extractSongTitleFromText(songText); 161 | if (!TextUtils.isEmpty(songTitle)) { 162 | intent.putExtra(MediaStore.EXTRA_MEDIA_TITLE, songTitle); 163 | } 164 | 165 | String artistTitle = Utils.extractArtistTitleFromText(songText); 166 | if (!TextUtils.isEmpty(artistTitle)) { 167 | intent.putExtra(MediaStore.EXTRA_MEDIA_ARTIST, artistTitle); 168 | } 169 | 170 | String queryText = songText; 171 | if (!TextUtils.isEmpty(songTitle) && !TextUtils.isEmpty(artistTitle)) { 172 | queryText = songTitle + " " + artistTitle; 173 | } 174 | intent.putExtra(SearchManager.QUERY, queryText); 175 | 176 | if (intent.resolveActivity(App.getContext().getPackageManager()) != null) { 177 | intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 178 | App.getContext().startActivity(intent); 179 | } 180 | } 181 | 182 | public static String getTimestampString(long timestamp) { 183 | return DateUtils.getRelativeTimeSpanString( 184 | timestamp, 185 | System.currentTimeMillis(), 186 | DateUtils.SECOND_IN_MILLIS, 187 | DateUtils.FORMAT_ABBREV_ALL).toString(); 188 | } 189 | 190 | public static boolean isLocationAccessGranted() { 191 | return ContextCompat.checkSelfPermission(App.getContext(), Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED; 192 | } 193 | 194 | public static void launchNowPlayingSettings() { 195 | try { 196 | Intent intent = new Intent(); 197 | intent.setComponent(new ComponentName("com.google.intelligence.sense", "com.google.intelligence.sense.ambientmusic.AmbientMusicSettingsActivity")); 198 | intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 199 | App.getContext().startActivity(intent); 200 | } catch (Exception e) { 201 | Toast.makeText(App.getContext(), "Failed to launch", Toast.LENGTH_SHORT).show(); 202 | } 203 | } 204 | 205 | public static boolean isEmpty(Collection collection) { 206 | return collection == null || collection.isEmpty(); 207 | } 208 | 209 | @SuppressLint("MissingPermission") 210 | public static Location getCurrentLocation() { 211 | if (Utils.isLocationAccessGranted()) { 212 | Task task = LocationServices.getFusedLocationProviderClient(App.getContext()).getLastLocation(); 213 | try { 214 | return Tasks.await(task); 215 | } catch (Exception e) { 216 | e.printStackTrace(); 217 | } 218 | } 219 | return null; 220 | } 221 | 222 | public static void composeFeedbackEmail() { 223 | try { 224 | int versionCode = BuildConfig.VERSION_CODE; 225 | String versionName = BuildConfig.VERSION_NAME; 226 | String[] addresses = new String[]{"playdevfeedback" + "@gmail.com"}; 227 | String subject = "Feedback"; 228 | String body = "\n\n\n\n" + 229 | "Version " + versionName + " (" + versionCode + ")\n" + 230 | "Android " + Build.VERSION.RELEASE + " (" + Build.VERSION.SDK_INT + ")\n" + 231 | Build.MANUFACTURER + " " + Build.MODEL; 232 | 233 | Intent intent = new Intent(Intent.ACTION_SENDTO); 234 | intent.setData(Uri.parse("mailto:")); 235 | intent.putExtra(Intent.EXTRA_EMAIL, addresses); 236 | intent.putExtra(Intent.EXTRA_SUBJECT, subject); 237 | intent.putExtra(Intent.EXTRA_TEXT, body); 238 | if (intent.resolveActivity(App.getContext().getPackageManager()) != null) { 239 | intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 240 | App.getContext().startActivity(intent); 241 | } 242 | } catch (Exception e) { 243 | Toast.makeText(App.getContext(), "Failed to launch", Toast.LENGTH_SHORT).show(); 244 | } 245 | } 246 | 247 | public static void openPlayStore() { 248 | try { 249 | Intent intent = new Intent(Intent.ACTION_VIEW); 250 | intent.setData(Uri.parse("market://details?id=" + App.getContext().getPackageName())); 251 | if (intent.resolveActivity(App.getContext().getPackageManager()) != null) { 252 | intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 253 | App.getContext().startActivity(intent); 254 | } 255 | } catch (Exception e) { 256 | Toast.makeText(App.getContext(), "Failed to launch", Toast.LENGTH_SHORT).show(); 257 | } 258 | } 259 | 260 | public static void styleMap(Resources resources, GoogleMap map) { 261 | int statusBarHeight = Utils.getAndroidDimenResource(resources, "status_bar_height"); 262 | int navigationBarHeight = Utils.getAndroidDimenResource(resources, "navigation_bar_height"); 263 | 264 | if (resources.getConfiguration().orientation == ORIENTATION_PORTRAIT) { 265 | map.setPadding(0, statusBarHeight, 0, navigationBarHeight); 266 | } else { 267 | map.setPadding(0, statusBarHeight, navigationBarHeight, 0); 268 | } 269 | 270 | int currentNightMode = resources.getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; 271 | if (Configuration.UI_MODE_NIGHT_YES == currentNightMode) { 272 | try { 273 | map.setMapStyle(MapStyleOptions.loadRawResourceStyle(App.getContext(), R.raw.map_night_theme)); 274 | } catch (Resources.NotFoundException e) { 275 | } 276 | } 277 | } 278 | 279 | public static int getAndroidDimenResource(Resources resources, String id) { 280 | int resourceId = resources.getIdentifier(id, "dimen", "android"); 281 | if (resourceId > 0) { 282 | return resources.getDimensionPixelSize(resourceId); 283 | } 284 | return 0; 285 | } 286 | 287 | public static String getFirstNLetters(String text, int n) { 288 | try { 289 | StringBuilder resultBuilder = new StringBuilder(); 290 | int resultLength = 0; 291 | for (int i = 0; i < text.length() && resultLength < n; ++i) { 292 | char c = text.charAt(i); 293 | if (Character.isAlphabetic(c)) { 294 | resultBuilder.append(c); 295 | resultLength++; 296 | } 297 | } 298 | 299 | return resultBuilder.toString(); 300 | } catch (Exception e) { 301 | } 302 | 303 | return null; 304 | } 305 | 306 | public static String getPlaceholderImageUrl(Song song) { 307 | String first2Letters = getFirstNLetters(song.getSongText(), 2); 308 | if (TextUtils.isEmpty(first2Letters)) { 309 | // Eh? 310 | first2Letters = "S"; 311 | } 312 | 313 | return "https://via.placeholder.com/175x175/aaaaaa/303f9f&text=" + first2Letters; 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/activities/IntroActivity.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.activities; 2 | 3 | import android.Manifest; 4 | import android.content.Intent; 5 | import android.graphics.Color; 6 | import android.os.Bundle; 7 | import androidx.fragment.app.Fragment; 8 | 9 | import com.github.paolorotolo.appintro.AppIntro2; 10 | import com.github.paolorotolo.appintro.AppIntro2Fragment; 11 | import com.github.paolorotolo.appintro.ISlidePolicy; 12 | import com.tanuj.nowplayinghistory.R; 13 | import com.tanuj.nowplayinghistory.Utils; 14 | import com.tanuj.nowplayinghistory.fragments.CustomPolicySlide; 15 | 16 | public class IntroActivity extends AppIntro2 { 17 | 18 | @Override 19 | protected void onCreate(Bundle savedInstanceState) { 20 | super.onCreate(savedInstanceState); 21 | 22 | addSlide(AppIntro2Fragment.newInstance( 23 | "Never lose any song you hear", 24 | "Works automatically in the background", 25 | R.drawable.slide_1, 26 | Color.parseColor("#3F51B5") 27 | )); 28 | 29 | addSlide(AppIntro2Fragment.newInstance( 30 | "Intuitive actions", 31 | "Filter by time, swipe to favorite/delete", 32 | R.drawable.slide_2, 33 | Color.parseColor("#E91E63") 34 | )); 35 | 36 | addSlide(CustomPolicySlide.newInstance( 37 | "Enable location data", 38 | "If you want, you can save location of the song", 39 | R.drawable.slide_3, 40 | Color.parseColor("#009688"), 41 | new ISlidePolicy() { 42 | @Override 43 | public boolean isPolicyRespected() { 44 | // This permission is optional 45 | return true; 46 | } 47 | 48 | @Override 49 | public void onUserIllegallyRequestedNextPage() { 50 | // Do nothing 51 | } 52 | } 53 | )); 54 | 55 | addSlide(CustomPolicySlide.newInstance( 56 | "Need notification access", 57 | "Uses notifications posted by your phone when it detects songs", 58 | R.drawable.slide_4, 59 | Color.parseColor("#4F4F4F"), 60 | new ISlidePolicy() { 61 | @Override 62 | public boolean isPolicyRespected() { 63 | return Utils.isNotificationAccessGranted(); 64 | } 65 | 66 | @Override 67 | public void onUserIllegallyRequestedNextPage() { 68 | Utils.LaunchNotificationAccessActivity(); 69 | } 70 | } 71 | )); 72 | 73 | // Ask for location permission on the third slide 74 | askForPermissions(new String[]{Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION}, 3); 75 | 76 | // Hide Skip/Done button. 77 | showSkipButton(false); 78 | } 79 | 80 | @Override 81 | public void onDonePressed(Fragment currentFragment) { 82 | finish(); 83 | Utils.setIsIntroRequired(false); 84 | startActivity(new Intent(this, MainActivity.class)); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/activities/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.activities; 2 | 3 | import android.app.ActivityOptions; 4 | import android.content.Intent; 5 | import android.os.Bundle; 6 | import androidx.annotation.NonNull; 7 | import com.google.android.material.bottomnavigation.BottomNavigationView; 8 | import com.google.android.material.floatingactionbutton.FloatingActionButton; 9 | import androidx.fragment.app.Fragment; 10 | import androidx.fragment.app.FragmentManager; 11 | import androidx.fragment.app.FragmentTransaction; 12 | import androidx.appcompat.app.AlertDialog; 13 | import androidx.appcompat.app.AppCompatActivity; 14 | import androidx.appcompat.widget.PopupMenu; 15 | import android.view.Menu; 16 | import android.view.MenuInflater; 17 | import android.view.MenuItem; 18 | import android.view.View; 19 | import android.widget.ImageButton; 20 | 21 | import com.tanuj.nowplayinghistory.App; 22 | import com.tanuj.nowplayinghistory.R; 23 | import com.tanuj.nowplayinghistory.Utils; 24 | import com.tanuj.nowplayinghistory.fragments.ListFragment; 25 | 26 | public class MainActivity extends AppCompatActivity implements BottomNavigationView.OnNavigationItemSelectedListener, PopupMenu.OnMenuItemClickListener { 27 | 28 | private static final long _24_HRS_MILLIS = 24 * 60 * 60 * 1000; 29 | private static final String EXTRA_SHOW_FAVORITES = "show_favorites"; 30 | private static final String EXTRA_FILTER = "fliter"; 31 | 32 | enum Filter { 33 | All, 34 | Last24Hrs, 35 | Last7Days, 36 | Last30Days 37 | } 38 | 39 | private Filter filter = Filter.All; 40 | private boolean showFavorites = false; 41 | 42 | @Override 43 | protected void onSaveInstanceState(Bundle outState) { 44 | super.onSaveInstanceState(outState); 45 | outState.putBoolean(EXTRA_SHOW_FAVORITES, showFavorites); 46 | outState.putSerializable(EXTRA_FILTER, filter); 47 | } 48 | 49 | @Override 50 | protected void onCreate(Bundle savedInstanceState) { 51 | super.onCreate(savedInstanceState); 52 | if (savedInstanceState != null) { 53 | showFavorites = savedInstanceState.getBoolean(EXTRA_SHOW_FAVORITES); 54 | filter = (Filter) savedInstanceState.getSerializable(EXTRA_FILTER); 55 | } 56 | 57 | setContentView(R.layout.activity_main); 58 | 59 | navigate(); 60 | 61 | ImageButton deleteButton = findViewById(R.id.clear_songs); 62 | deleteButton.setOnClickListener(view -> showClearSongsDialog()); 63 | 64 | ImageButton filterButton = findViewById(R.id.filter_songs); 65 | filterButton.setOnClickListener(this::showFilterSongsPopup); 66 | 67 | BottomNavigationView bottomNavigationView = findViewById(R.id.bottom_nav); 68 | bottomNavigationView.setSelectedItemId(showFavorites ? R.id.action_favorites : R.id.action_recents); 69 | bottomNavigationView.setOnNavigationItemSelectedListener(this); 70 | 71 | FloatingActionButton mapFab = findViewById(R.id.map_fab); 72 | mapFab.setOnClickListener(v -> { 73 | int[] location = new int[2]; 74 | mapFab.getLocationInWindow(location); 75 | int revealX = location[0] + mapFab.getWidth() / 2; 76 | int revealY = location[1] + mapFab.getHeight() / 2; 77 | 78 | Intent intent = new Intent(MainActivity.this, MapActivity.class); 79 | intent.putExtra(MapActivity.EXTRA_CIRCULAR_REVEAL_X, revealX); 80 | intent.putExtra(MapActivity.EXTRA_CIRCULAR_REVEAL_Y, revealY); 81 | intent.putExtra(MapActivity.EXTRA_SHOW_FAVORITES, showFavorites); 82 | intent.putExtra(MapActivity.EXTRA_MIN_TIMESTAMP, getMinTimestamp()); 83 | 84 | ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(MainActivity.this, mapFab, "transition"); 85 | startActivity(intent, options.toBundle()); 86 | }); 87 | } 88 | 89 | @Override 90 | public boolean onNavigationItemSelected(@NonNull MenuItem item) { 91 | showFavorites = item.getItemId() == R.id.action_favorites; 92 | navigate(); 93 | return true; 94 | } 95 | 96 | public void showFilterSongsPopup(View view) { 97 | PopupMenu popup = new PopupMenu(this, view); 98 | popup.setOnMenuItemClickListener(this); 99 | MenuInflater inflater = popup.getMenuInflater(); 100 | inflater.inflate(R.menu.filter_songs, popup.getMenu()); 101 | 102 | switch (filter) { 103 | case All: 104 | popup.getMenu().findItem(R.id.action_filter_all).setChecked(true); 105 | break; 106 | case Last24Hrs: 107 | popup.getMenu().findItem(R.id.action_filter_last_24_hrs).setChecked(true); 108 | break; 109 | case Last7Days: 110 | popup.getMenu().findItem(R.id.action_filter_last_7_days).setChecked(true); 111 | break; 112 | case Last30Days: 113 | popup.getMenu().findItem(R.id.action_filter_last_30_days).setChecked(true); 114 | break; 115 | } 116 | 117 | popup.show(); 118 | } 119 | 120 | @Override 121 | public boolean onMenuItemClick(MenuItem item) { 122 | switch (item.getItemId()) { 123 | case R.id.action_filter_all: 124 | filter = Filter.All; 125 | break; 126 | case R.id.action_filter_last_24_hrs: 127 | filter = Filter.Last24Hrs; 128 | break; 129 | case R.id.action_filter_last_7_days: 130 | filter = Filter.Last7Days; 131 | break; 132 | case R.id.action_filter_last_30_days: 133 | filter = Filter.Last30Days; 134 | break; 135 | } 136 | 137 | navigate(); 138 | return true; 139 | } 140 | 141 | private void navigate() { 142 | Fragment fragment = ListFragment.newInstance(showFavorites, getMinTimestamp()); 143 | FragmentManager fragmentManager = getSupportFragmentManager(); 144 | FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); 145 | fragmentTransaction.replace(R.id.container, fragment); 146 | fragmentTransaction.commit(); 147 | } 148 | 149 | private long getMinTimestamp() { 150 | switch (filter) { 151 | case All: 152 | return 0; 153 | case Last24Hrs: 154 | return System.currentTimeMillis() - _24_HRS_MILLIS; 155 | case Last7Days: 156 | return System.currentTimeMillis() - 7 * _24_HRS_MILLIS; 157 | case Last30Days: 158 | return System.currentTimeMillis() - 30 * _24_HRS_MILLIS; 159 | } 160 | return 0; 161 | } 162 | 163 | private void showClearSongsDialog() { 164 | String[] options = new String[]{"90 days", "30 days", "7 days"}; 165 | boolean favorites = showFavorites; 166 | String group = favorites ? "favorites" : "recents"; 167 | AlertDialog.Builder builder = new AlertDialog.Builder(this); 168 | builder.setTitle("Clear " + group + " older than") 169 | .setNeutralButton("Clear All", (dialog, which) -> { 170 | dialog.dismiss(); 171 | 172 | showConfirmClearSongsDialog("Do you want to clear all " + group + "?", favorites, System.currentTimeMillis()); 173 | }) 174 | .setNegativeButton("Cancel", (dialog, which) -> { 175 | dialog.dismiss(); 176 | }) 177 | .setItems(options, (dialog, which) -> { 178 | dialog.dismiss(); 179 | 180 | long maxTimestamp = System.currentTimeMillis(); 181 | switch (which) { 182 | case 0: 183 | maxTimestamp -= 90 * _24_HRS_MILLIS; 184 | break; 185 | case 1: 186 | maxTimestamp -= 30 * _24_HRS_MILLIS; 187 | break; 188 | case 2: 189 | maxTimestamp -= 7 * _24_HRS_MILLIS; 190 | break; 191 | default: 192 | return; 193 | } 194 | 195 | showConfirmClearSongsDialog("Do you want to clear " + group + " older than " + options[which] + "?", favorites, maxTimestamp); 196 | }); 197 | builder.create().show(); 198 | } 199 | 200 | private void showConfirmClearSongsDialog(String message, boolean favorites, long maxTimestamp) { 201 | AlertDialog.Builder builder = new AlertDialog.Builder(this); 202 | builder.setTitle("Clear songs") 203 | .setMessage(message) 204 | .setPositiveButton("Clear", (dialog, which) -> { 205 | dialog.dismiss(); 206 | 207 | if (favorites) { 208 | Utils.executeAsync(() -> App.getDb().songDao().deleteAllFavSongs(maxTimestamp)); 209 | } else { 210 | Utils.executeAsync(() -> App.getDb().songDao().deleteAllSongs(maxTimestamp)); 211 | } 212 | }) 213 | .setNegativeButton("Cancel", (dialog, which) -> { 214 | dialog.dismiss(); 215 | }); 216 | builder.create().show(); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/activities/MapActivity.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.activities; 2 | 3 | import android.animation.Animator; 4 | import android.animation.AnimatorListenerAdapter; 5 | import android.os.Bundle; 6 | import androidx.annotation.Nullable; 7 | import androidx.fragment.app.Fragment; 8 | import androidx.fragment.app.FragmentManager; 9 | import androidx.fragment.app.FragmentTransaction; 10 | import androidx.appcompat.app.AppCompatActivity; 11 | import android.view.View; 12 | import android.view.ViewAnimationUtils; 13 | import android.view.ViewTreeObserver; 14 | import android.view.animation.AccelerateInterpolator; 15 | 16 | import com.tanuj.nowplayinghistory.R; 17 | import com.tanuj.nowplayinghistory.fragments.MapFragment; 18 | 19 | public class MapActivity extends AppCompatActivity { 20 | 21 | public static final String EXTRA_CIRCULAR_REVEAL_X = "EXTRA_CIRCULAR_REVEAL_X"; 22 | public static final String EXTRA_CIRCULAR_REVEAL_Y = "EXTRA_CIRCULAR_REVEAL_Y"; 23 | public static final String EXTRA_SHOW_FAVORITES = "show_favorites"; 24 | public static final String EXTRA_MIN_TIMESTAMP = "min_timestamp"; 25 | 26 | private View root; 27 | 28 | private int revealX; 29 | private int revealY; 30 | 31 | private boolean useReveal; 32 | private boolean unRevealInProgress; 33 | 34 | @Override 35 | protected void onCreate(@Nullable Bundle savedInstanceState) { 36 | super.onCreate(savedInstanceState); 37 | 38 | setContentView(R.layout.activity_map); 39 | 40 | root = findViewById(R.id.container); 41 | 42 | revealX = getIntent().getExtras().getInt(EXTRA_CIRCULAR_REVEAL_X); 43 | revealY = getIntent().getExtras().getInt(EXTRA_CIRCULAR_REVEAL_Y); 44 | boolean showFavorites = getIntent().getExtras().getBoolean(EXTRA_SHOW_FAVORITES); 45 | long minTimestamp = getIntent().getExtras().getLong(EXTRA_MIN_TIMESTAMP); 46 | 47 | Fragment fragment = MapFragment.newInstance(showFavorites, minTimestamp); 48 | FragmentManager fragmentManager = getSupportFragmentManager(); 49 | FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); 50 | fragmentTransaction.replace(R.id.container, fragment); 51 | fragmentTransaction.commit(); 52 | 53 | if (savedInstanceState == null) { 54 | ViewTreeObserver viewTreeObserver = root.getViewTreeObserver(); 55 | if (viewTreeObserver.isAlive()) { 56 | viewTreeObserver.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { 57 | @Override 58 | public void onGlobalLayout() { 59 | useReveal = true; 60 | revealActivity(); 61 | root.getViewTreeObserver().removeOnGlobalLayoutListener(this); 62 | } 63 | }); 64 | } 65 | } 66 | } 67 | 68 | protected void revealActivity() { 69 | float finalRadius = (float) (Math.max(root.getWidth(), root.getHeight())); 70 | 71 | // create the animator for this view (the start radius is zero) 72 | Animator circularReveal = ViewAnimationUtils.createCircularReveal(root, revealX, revealY, 0, finalRadius); 73 | circularReveal.setDuration(400); 74 | circularReveal.setInterpolator(new AccelerateInterpolator()); 75 | circularReveal.start(); 76 | } 77 | 78 | protected void unrevealActivity() { 79 | if (!unRevealInProgress) { 80 | unRevealInProgress = true; 81 | 82 | float finalRadius = (float) (Math.max(root.getWidth(), root.getHeight())); 83 | Animator circularReveal = ViewAnimationUtils.createCircularReveal(root, revealX, revealY, finalRadius, 0); 84 | 85 | circularReveal.setDuration(400); 86 | circularReveal.addListener(new AnimatorListenerAdapter() { 87 | @Override 88 | public void onAnimationEnd(Animator animation) { 89 | unRevealInProgress = false; 90 | finish(); 91 | overridePendingTransition(0, 0); 92 | } 93 | }); 94 | 95 | circularReveal.start(); 96 | } 97 | } 98 | 99 | @Override 100 | public void onBackPressed() { 101 | if (useReveal) { 102 | unrevealActivity(); 103 | } else { 104 | super.onBackPressed(); 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/activities/SplashActivity.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.activities; 2 | 3 | import android.content.Intent; 4 | import androidx.appcompat.app.AppCompatActivity; 5 | 6 | import com.tanuj.nowplayinghistory.Utils; 7 | 8 | public class SplashActivity extends AppCompatActivity { 9 | 10 | @Override 11 | protected void onStart() { 12 | super.onStart(); 13 | 14 | Utils.executeAsync(() -> { 15 | if (Utils.isIntroRequired()) { 16 | try { 17 | Thread.sleep(500); 18 | } catch (InterruptedException e) { 19 | e.printStackTrace(); 20 | } 21 | } 22 | }, () -> { 23 | if (Utils.isIntroRequired()) { 24 | startActivity(new Intent(SplashActivity.this, IntroActivity.class)); 25 | } else { 26 | startActivity(new Intent(SplashActivity.this, MainActivity.class)); 27 | } 28 | finish(); 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/adapters/SongsListAdapter.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.adapters; 2 | 3 | import android.view.LayoutInflater; 4 | import android.view.View; 5 | import android.view.ViewGroup; 6 | import android.widget.ImageView; 7 | 8 | import com.tanuj.nowplayinghistory.R; 9 | import com.tanuj.nowplayinghistory.Utils; 10 | import com.tanuj.nowplayinghistory.databinding.SongInfoBinding; 11 | import com.tanuj.nowplayinghistory.lastfm.GlideApp; 12 | import com.tanuj.nowplayinghistory.persistence.Song; 13 | 14 | import java.util.ArrayList; 15 | import java.util.List; 16 | 17 | import androidx.databinding.BindingAdapter; 18 | import androidx.databinding.DataBindingUtil; 19 | import androidx.recyclerview.widget.RecyclerView; 20 | 21 | public class SongsListAdapter extends RecyclerView.Adapter { 22 | 23 | private List songsList = new ArrayList<>(); 24 | 25 | public static class SongViewHolder extends RecyclerView.ViewHolder { 26 | public SongInfoBinding binding; 27 | 28 | public SongViewHolder(SongInfoBinding binding) { 29 | super(binding.getRoot()); 30 | this.binding = binding; 31 | } 32 | 33 | public Song getSong() { 34 | return binding.getSong(); 35 | } 36 | 37 | @BindingAdapter({"bind:song"}) 38 | public static void loadImage(ImageView view, Song song) { 39 | GlideApp.with(view).load(song).placeholder(R.drawable.ic_album).into(view); 40 | } 41 | 42 | public void onSongClick(View view) { 43 | Utils.launchMusicApp(binding.getSong().getSongText()); 44 | } 45 | } 46 | 47 | public void setList(List songs) { 48 | songsList.clear(); 49 | songsList.addAll(songs); 50 | notifyDataSetChanged(); 51 | } 52 | 53 | @Override 54 | public SongViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { 55 | SongInfoBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()), R.layout.song_info, parent, false); 56 | return new SongViewHolder(binding); 57 | } 58 | 59 | @Override 60 | public void onBindViewHolder(SongViewHolder holder, int position) { 61 | Song song = songsList.get(position); 62 | onBindViewHolderImpl(holder, song); 63 | } 64 | 65 | @Override 66 | public int getItemCount() { 67 | return songsList.size(); 68 | } 69 | 70 | void onBindViewHolderImpl(SongViewHolder holder, Song song) { 71 | holder.binding.setSong(song); 72 | holder.binding.setSongViewHolder(holder); 73 | holder.binding.executePendingBindings(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/adapters/SongsPagedListAdapter.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.adapters; 2 | 3 | import androidx.paging.PagedListAdapter; 4 | import androidx.annotation.NonNull; 5 | import androidx.annotation.Nullable; 6 | import androidx.recyclerview.widget.DiffUtil; 7 | import android.view.ViewGroup; 8 | 9 | import com.tanuj.nowplayinghistory.persistence.Song; 10 | 11 | public class SongsPagedListAdapter extends PagedListAdapter { 12 | 13 | private SongsListAdapter songsListAdapter = new SongsListAdapter(); 14 | 15 | public static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { 16 | 17 | @Override 18 | public boolean areItemsTheSame(@NonNull Song oldItem, @NonNull Song newItem) { 19 | return oldItem.getTimestamp() == newItem.getTimestamp(); 20 | } 21 | 22 | @Override 23 | public boolean areContentsTheSame(@NonNull Song oldItem, @NonNull Song newItem) { 24 | return oldItem.equals(newItem); 25 | } 26 | }; 27 | 28 | public SongsPagedListAdapter() { 29 | super(DIFF_CALLBACK); 30 | } 31 | 32 | @Override 33 | public SongsListAdapter.SongViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { 34 | return songsListAdapter.onCreateViewHolder(parent, viewType); 35 | } 36 | 37 | @Override 38 | public void onBindViewHolder(SongsListAdapter.SongViewHolder holder, int position) { 39 | Song song = getItem(position); 40 | songsListAdapter.onBindViewHolderImpl(holder, song); 41 | } 42 | 43 | @Nullable 44 | @Override 45 | public Song getItem(int position) { 46 | return super.getItem(position); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/behaviors/CustomFloatingActionButtonBehavior.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.behaviors; 2 | 3 | import android.content.Context; 4 | import androidx.coordinatorlayout.widget.CoordinatorLayout; 5 | import com.google.android.material.floatingactionbutton.FloatingActionButton; 6 | import androidx.core.view.ViewCompat; 7 | import androidx.interpolator.view.animation.FastOutSlowInInterpolator; 8 | import android.util.AttributeSet; 9 | import android.view.View; 10 | 11 | import com.tanuj.nowplayinghistory.views.RateMeView; 12 | 13 | public class CustomFloatingActionButtonBehavior extends FloatingActionButton.Behavior { 14 | 15 | public CustomFloatingActionButtonBehavior() { 16 | super(); 17 | } 18 | 19 | public CustomFloatingActionButtonBehavior(Context context, AttributeSet attrs) { 20 | super(context, attrs); 21 | } 22 | 23 | @Override 24 | public boolean layoutDependsOn(CoordinatorLayout parent, FloatingActionButton child, View dependency) { 25 | boolean result = dependency instanceof RateMeView; 26 | return result || super.layoutDependsOn(parent, child, dependency); 27 | } 28 | 29 | @Override 30 | public boolean onDependentViewChanged(CoordinatorLayout parent, FloatingActionButton child, View dependency) { 31 | if (dependency instanceof RateMeView) { 32 | updateFabTranslationForRateMeView(child, dependency); 33 | } 34 | return super.onDependentViewChanged(parent, child, dependency); 35 | } 36 | 37 | @Override 38 | public void onDependentViewRemoved(CoordinatorLayout parent, FloatingActionButton child, View dependency) { 39 | if (dependency instanceof RateMeView && child.getTranslationY() != 0.0F) { 40 | ViewCompat.animate(child).translationY(0.0F).scaleX(1.0F).scaleY(1.0F).alpha(1.0F).setInterpolator(new FastOutSlowInInterpolator()).start(); 41 | } 42 | super.onDependentViewRemoved(parent, child, dependency); 43 | } 44 | 45 | private void updateFabTranslationForRateMeView(FloatingActionButton child, View dependency) { 46 | float translationY = Math.min(0, dependency.getTranslationY() - dependency.getHeight()); 47 | child.setTranslationY(translationY); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/behaviors/RateMeViewBehavior.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.behaviors; 2 | 3 | import android.animation.Animator; 4 | import android.animation.AnimatorListenerAdapter; 5 | import android.content.Context; 6 | import androidx.annotation.NonNull; 7 | import androidx.coordinatorlayout.widget.CoordinatorLayout; 8 | import androidx.core.view.ViewCompat; 9 | import androidx.interpolator.view.animation.FastOutSlowInInterpolator; 10 | import android.util.AttributeSet; 11 | import android.view.View; 12 | import android.view.animation.Interpolator; 13 | 14 | import com.tanuj.nowplayinghistory.views.RateMeView; 15 | 16 | public class RateMeViewBehavior extends CoordinatorLayout.Behavior { 17 | 18 | private static final Interpolator INTERPOLATOR = new FastOutSlowInInterpolator(); 19 | 20 | private static final int ANIM_STATE_NONE = 0; 21 | private static final int ANIM_STATE_HIDING = 1; 22 | private static final int ANIM_STATE_SHOWING = 2; 23 | 24 | private int animState = ANIM_STATE_NONE; 25 | 26 | private int mDySinceDirectionChange; 27 | 28 | public RateMeViewBehavior() { 29 | super(); 30 | } 31 | 32 | public RateMeViewBehavior(Context context, AttributeSet attrs) { 33 | super(context, attrs); 34 | } 35 | 36 | @Override 37 | public boolean onStartNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull RateMeView child, @NonNull View directTargetChild, @NonNull View target, @ViewCompat.ScrollAxis int axes, @ViewCompat.NestedScrollType int type) { 38 | return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; 39 | } 40 | 41 | @Override 42 | public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull RateMeView child, @NonNull View target, int dx, int dy, @NonNull int[] consumed, @ViewCompat.NestedScrollType int type) { 43 | if (dy > 0 && mDySinceDirectionChange < 0 44 | || dy < 0 && mDySinceDirectionChange > 0) { 45 | // We detected a direction change -- reset our cumulative delta Y 46 | mDySinceDirectionChange = 0; 47 | } 48 | 49 | mDySinceDirectionChange += dy; 50 | 51 | if (mDySinceDirectionChange > child.getHeight() && !isOrWillBeHidden(child)) { 52 | hide(child); 53 | } else if (mDySinceDirectionChange < 0 && !isOrWillBeShown(child)) { 54 | show(child); 55 | } 56 | } 57 | 58 | private boolean isOrWillBeHidden(View view) { 59 | if (view.getVisibility() == View.VISIBLE) { 60 | return animState == ANIM_STATE_HIDING; 61 | } else { 62 | return animState != ANIM_STATE_SHOWING; 63 | } 64 | } 65 | 66 | private boolean isOrWillBeShown(View view) { 67 | if (view.getVisibility() != View.VISIBLE) { 68 | return animState == ANIM_STATE_SHOWING; 69 | } else { 70 | return animState != ANIM_STATE_HIDING; 71 | } 72 | } 73 | 74 | /** 75 | * Hide the quick return view. 76 | *

77 | * Animates hiding the view, with the view sliding down and out of the screen. 78 | * After the view has disappeared, its visibility will change to GONE. 79 | * 80 | * @param view The quick return view 81 | */ 82 | private void hide(final View view) { 83 | view.animate().cancel(); 84 | 85 | view.animate() 86 | .translationY(view.getHeight()) 87 | .setInterpolator(INTERPOLATOR) 88 | .setDuration(200) 89 | .setListener(new AnimatorListenerAdapter() { 90 | private boolean isCanceled = false; 91 | 92 | @Override 93 | public void onAnimationStart(Animator animation) { 94 | animState = ANIM_STATE_HIDING; 95 | isCanceled = false; 96 | view.setVisibility(View.VISIBLE); 97 | } 98 | 99 | @Override 100 | public void onAnimationCancel(Animator animation) { 101 | isCanceled = true; 102 | } 103 | 104 | @Override 105 | public void onAnimationEnd(Animator animation) { 106 | animState = ANIM_STATE_NONE; 107 | if (!isCanceled) { 108 | view.setVisibility(View.INVISIBLE); 109 | } 110 | } 111 | }); 112 | } 113 | 114 | /** 115 | * Show the quick return view. 116 | *

117 | * Animates showing the view, with the view sliding up from the bottom of the screen. 118 | * After the view has reappeared, its visibility will change to VISIBLE. 119 | * 120 | * @param view The quick return view 121 | */ 122 | private void show(final View view) { 123 | view.animate().cancel(); 124 | 125 | view.animate() 126 | .translationY(0f) 127 | .setInterpolator(INTERPOLATOR) 128 | .setDuration(200) 129 | .setListener(new AnimatorListenerAdapter() { 130 | @Override 131 | public void onAnimationStart(Animator animator) { 132 | animState = ANIM_STATE_SHOWING; 133 | view.setVisibility(View.VISIBLE); 134 | } 135 | 136 | @Override 137 | public void onAnimationEnd(Animator animator) { 138 | animState = ANIM_STATE_NONE; 139 | } 140 | }); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/callbacks/FavoritesItemTouchCallback.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.callbacks; 2 | 3 | import android.graphics.Canvas; 4 | import com.google.android.material.snackbar.Snackbar; 5 | import androidx.recyclerview.widget.RecyclerView; 6 | import androidx.recyclerview.widget.ItemTouchHelper; 7 | import android.view.View; 8 | 9 | import com.tanuj.nowplayinghistory.App; 10 | import com.tanuj.nowplayinghistory.SwipeAction; 11 | import com.tanuj.nowplayinghistory.Utils; 12 | import com.tanuj.nowplayinghistory.adapters.SongsPagedListAdapter; 13 | import com.tanuj.nowplayinghistory.persistence.FavSong; 14 | import com.tanuj.nowplayinghistory.persistence.Song; 15 | 16 | public class FavoritesItemTouchCallback extends ItemTouchHelper.SimpleCallback { 17 | 18 | private final SwipeAction removeSwipeAction = new SwipeAction(SwipeAction.Dir.LEFT, "Remove"); 19 | 20 | private final View anchor; 21 | private final SongsPagedListAdapter adapter; 22 | 23 | public FavoritesItemTouchCallback(View anchor, SongsPagedListAdapter adapter) { 24 | super(0, ItemTouchHelper.LEFT); 25 | this.anchor = anchor; 26 | this.adapter = adapter; 27 | } 28 | 29 | @Override 30 | public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) { 31 | return false; 32 | } 33 | 34 | @Override 35 | public void onSwiped(final RecyclerView.ViewHolder viewHolder, int swipeDir) { 36 | final int position = viewHolder.getAdapterPosition(); 37 | Song song = adapter.getItem(position); 38 | final FavSong favSong = new FavSong(song); 39 | 40 | if (swipeDir == ItemTouchHelper.LEFT) { 41 | Utils.executeAsync(() -> App.getDb().songDao().delete(favSong)); 42 | 43 | String message = "Removed " + favSong.getSongText() + " from favorites"; 44 | Snackbar.make(anchor, message, Snackbar.LENGTH_SHORT) 45 | .setAction("Undo", (view) -> Utils.executeAsync(() -> App.getDb().songDao().insert(favSong))) 46 | .show(); 47 | } 48 | } 49 | 50 | @Override 51 | public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { 52 | View itemView = viewHolder.itemView; 53 | if (dX < 0) { 54 | removeSwipeAction.draw(c, itemView, dX); 55 | } 56 | 57 | super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/callbacks/RecentsItemTouchCallback.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.callbacks; 2 | 3 | import android.database.sqlite.SQLiteConstraintException; 4 | import android.graphics.Canvas; 5 | import com.google.android.material.snackbar.Snackbar; 6 | import androidx.recyclerview.widget.RecyclerView; 7 | import androidx.recyclerview.widget.ItemTouchHelper; 8 | import android.view.View; 9 | 10 | import com.tanuj.nowplayinghistory.App; 11 | import com.tanuj.nowplayinghistory.SwipeAction; 12 | import com.tanuj.nowplayinghistory.Utils; 13 | import com.tanuj.nowplayinghistory.adapters.SongsPagedListAdapter; 14 | import com.tanuj.nowplayinghistory.persistence.FavSong; 15 | import com.tanuj.nowplayinghistory.persistence.Song; 16 | 17 | public class RecentsItemTouchCallback extends ItemTouchHelper.SimpleCallback { 18 | 19 | private final SwipeAction deleteSwipeAction = new SwipeAction(SwipeAction.Dir.LEFT, "Delete"); 20 | private final SwipeAction favoriteSwipeAction = new SwipeAction(SwipeAction.Dir.RIGHT, "Favorite"); 21 | 22 | private final View anchor; 23 | private final SongsPagedListAdapter adapter; 24 | 25 | public RecentsItemTouchCallback(View anchor, SongsPagedListAdapter adapter) { 26 | super(0, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT); 27 | this.anchor = anchor; 28 | this.adapter = adapter; 29 | } 30 | 31 | @Override 32 | public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) { 33 | return false; 34 | } 35 | 36 | @Override 37 | public void onSwiped(final RecyclerView.ViewHolder viewHolder, int swipeDir) { 38 | final int position = viewHolder.getAdapterPosition(); 39 | final Song song = adapter.getItem(position); 40 | 41 | if (swipeDir == ItemTouchHelper.LEFT) { 42 | Utils.executeAsync(() -> App.getDb().songDao().delete(song)); 43 | 44 | String message = "Deleted " + song.getSongText(); 45 | Snackbar.make(anchor, message, Snackbar.LENGTH_SHORT) 46 | .setAction("Undo", (view) -> Utils.executeAsync(() -> App.getDb().songDao().insert(song))) 47 | .show(); 48 | } else { 49 | final FavSong favSong = new FavSong(song); 50 | Utils.executeAsync(() -> { 51 | try { 52 | App.getDb().songDao().insert(favSong); 53 | } catch (SQLiteConstraintException e) { 54 | } 55 | }, () -> adapter.notifyItemChanged(position)); 56 | 57 | String message = "Added " + song.getSongText() + " to favorites"; 58 | Snackbar.make(anchor, message, Snackbar.LENGTH_SHORT) 59 | .setAction("Undo", (view) -> Utils.executeAsync(() -> App.getDb().songDao().delete(favSong))) 60 | .show(); 61 | } 62 | } 63 | 64 | @Override 65 | public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { 66 | View itemView = viewHolder.itemView; 67 | if (dX < 0) { 68 | deleteSwipeAction.draw(c, itemView, dX); 69 | } else { 70 | favoriteSwipeAction.draw(c, itemView, dX); 71 | } 72 | 73 | super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/fragments/ClusterDialogFragment.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.fragments; 2 | 3 | import android.os.Bundle; 4 | import androidx.annotation.NonNull; 5 | import androidx.annotation.Nullable; 6 | import com.google.android.material.bottomsheet.BottomSheetDialogFragment; 7 | import androidx.recyclerview.widget.LinearLayoutManager; 8 | import androidx.recyclerview.widget.RecyclerView; 9 | import android.view.LayoutInflater; 10 | import android.view.View; 11 | import android.view.ViewGroup; 12 | 13 | import com.google.android.gms.maps.CameraUpdateFactory; 14 | import com.google.android.gms.maps.GoogleMap; 15 | import com.google.android.gms.maps.MapView; 16 | import com.google.android.gms.maps.OnMapReadyCallback; 17 | import com.google.android.gms.maps.model.LatLng; 18 | import com.google.android.gms.maps.model.MarkerOptions; 19 | import com.google.maps.android.clustering.Cluster; 20 | import com.tanuj.nowplayinghistory.MapItem; 21 | import com.tanuj.nowplayinghistory.R; 22 | import com.tanuj.nowplayinghistory.Utils; 23 | import com.tanuj.nowplayinghistory.adapters.SongsListAdapter; 24 | import com.tanuj.nowplayinghistory.persistence.Song; 25 | 26 | import java.util.ArrayList; 27 | import java.util.List; 28 | 29 | public class ClusterDialogFragment extends BottomSheetDialogFragment implements OnMapReadyCallback { 30 | 31 | private static final String EXTRA_CLUSTER_ITEMS = "cluster"; 32 | private static final String EXTRA_CLUSTER_POSITION = "cluster-position"; 33 | 34 | private MapView mapView; 35 | private RecyclerView recyclerView; 36 | private ArrayList mapItems; 37 | private LatLng position; 38 | 39 | public static ClusterDialogFragment newInstance(Cluster cluster) { 40 | ClusterDialogFragment fragment = new ClusterDialogFragment(); 41 | 42 | Bundle args = new Bundle(); 43 | args.putParcelableArrayList(EXTRA_CLUSTER_ITEMS, new ArrayList<>(cluster.getItems())); 44 | args.putParcelable(EXTRA_CLUSTER_POSITION, cluster.getPosition()); 45 | fragment.setArguments(args); 46 | 47 | return fragment; 48 | } 49 | 50 | @Override 51 | public void onCreate(@Nullable Bundle savedInstanceState) { 52 | super.onCreate(savedInstanceState); 53 | if (getArguments() != null) { 54 | mapItems = getArguments().getParcelableArrayList(EXTRA_CLUSTER_ITEMS); 55 | position = getArguments().getParcelable(EXTRA_CLUSTER_POSITION); 56 | } 57 | } 58 | 59 | @Nullable 60 | @Override 61 | public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { 62 | View view = inflater.inflate(R.layout.cluster_dialog, container, false); 63 | 64 | mapView = view.findViewById(R.id.map); 65 | mapView.onCreate(savedInstanceState); 66 | mapView.getMapAsync(this); 67 | recyclerView = view.findViewById(R.id.recycler_view); 68 | recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); 69 | 70 | SongsListAdapter adapter = new SongsListAdapter(); 71 | adapter.setList(getSongs()); 72 | recyclerView.setAdapter(adapter); 73 | 74 | return view; 75 | } 76 | 77 | @Override 78 | public void onDestroyView() { 79 | super.onDestroyView(); 80 | mapView.onDestroy(); 81 | } 82 | 83 | private List getSongs() { 84 | List songs = new ArrayList<>(); 85 | for (MapItem mapItem : mapItems) { 86 | songs.add(mapItem.getSong()); 87 | } 88 | return songs; 89 | } 90 | 91 | @Override 92 | public void onMapReady(GoogleMap googleMap) { 93 | Utils.styleMap(getResources(), googleMap); 94 | googleMap.addMarker(new MarkerOptions().position(position)); 95 | googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(position, 16)); 96 | } 97 | 98 | @Override 99 | public void onStart() { 100 | super.onStart(); 101 | mapView.onStart(); 102 | } 103 | 104 | @Override 105 | public void onResume() { 106 | super.onResume(); 107 | mapView.onResume(); 108 | } 109 | 110 | @Override 111 | public void onPause() { 112 | super.onPause(); 113 | mapView.onPause(); 114 | } 115 | 116 | @Override 117 | public void onStop() { 118 | super.onStop(); 119 | mapView.onStop(); 120 | } 121 | 122 | @Override 123 | public void onSaveInstanceState(Bundle outState) { 124 | super.onSaveInstanceState(outState); 125 | mapView.onSaveInstanceState(outState); 126 | } 127 | 128 | @Override 129 | public void onLowMemory() { 130 | super.onLowMemory(); 131 | mapView.onLowMemory(); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/fragments/CustomPolicySlide.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.fragments; 2 | 3 | import android.os.Bundle; 4 | 5 | import com.github.paolorotolo.appintro.AppIntroBaseFragment; 6 | import com.github.paolorotolo.appintro.ISlidePolicy; 7 | 8 | import androidx.annotation.ColorInt; 9 | import androidx.annotation.DrawableRes; 10 | 11 | public class CustomPolicySlide extends AppIntroBaseFragment implements ISlidePolicy { 12 | 13 | private ISlidePolicy slidePolicy; 14 | 15 | public static CustomPolicySlide newInstance(CharSequence title, CharSequence description, @DrawableRes int imageDrawable, @ColorInt int bgColor, ISlidePolicy slidePolicy) { 16 | CustomPolicySlide slide = new CustomPolicySlide(); 17 | Bundle args = new Bundle(); 18 | args.putString(ARG_TITLE, title.toString()); 19 | args.putString(ARG_TITLE_TYPEFACE, null); 20 | args.putString(ARG_DESC, description.toString()); 21 | args.putString(ARG_DESC_TYPEFACE, null); 22 | args.putInt(ARG_DRAWABLE, imageDrawable); 23 | args.putInt(ARG_BG_COLOR, bgColor); 24 | args.putInt(ARG_TITLE_COLOR, 0); 25 | args.putInt(ARG_DESC_COLOR, 0); 26 | slide.setArguments(args); 27 | slide.setSlidePolicy(slidePolicy); 28 | 29 | return slide; 30 | } 31 | 32 | protected void setSlidePolicy(ISlidePolicy slidePolicy) { 33 | this.slidePolicy = slidePolicy; 34 | } 35 | 36 | @Override 37 | protected int getLayoutId() { 38 | return com.github.paolorotolo.appintro.R.layout.appintro_fragment_intro2; 39 | } 40 | 41 | @Override 42 | public boolean isPolicyRespected() { 43 | if (slidePolicy != null) { 44 | return slidePolicy.isPolicyRespected(); 45 | } 46 | return false; 47 | } 48 | 49 | @Override 50 | public void onUserIllegallyRequestedNextPage() { 51 | if (slidePolicy != null) { 52 | slidePolicy.onUserIllegallyRequestedNextPage(); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/fragments/EmptyFragment.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.fragments; 2 | 3 | import android.os.Bundle; 4 | import androidx.annotation.NonNull; 5 | import androidx.annotation.Nullable; 6 | import androidx.fragment.app.Fragment; 7 | import android.view.LayoutInflater; 8 | import android.view.View; 9 | import android.view.ViewGroup; 10 | 11 | import com.tanuj.nowplayinghistory.R; 12 | import com.tanuj.nowplayinghistory.Utils; 13 | 14 | public class EmptyFragment extends Fragment { 15 | 16 | @Nullable 17 | @Override 18 | public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { 19 | View view = inflater.inflate(R.layout.empty_state, container, false); 20 | view.findViewById(R.id.launch_now_playing_settings).setOnClickListener(v -> Utils.launchNowPlayingSettings()); 21 | return view; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/fragments/ListFragment.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.fragments; 2 | 3 | import androidx.lifecycle.ViewModelProviders; 4 | import android.content.Intent; 5 | import android.os.Bundle; 6 | import android.provider.Settings; 7 | import androidx.annotation.NonNull; 8 | import androidx.annotation.Nullable; 9 | import com.google.android.material.snackbar.Snackbar; 10 | import androidx.fragment.app.Fragment; 11 | import androidx.recyclerview.widget.LinearLayoutManager; 12 | import androidx.recyclerview.widget.RecyclerView; 13 | import androidx.recyclerview.widget.ItemTouchHelper; 14 | import android.view.LayoutInflater; 15 | import android.view.View; 16 | import android.view.ViewGroup; 17 | 18 | import com.tanuj.nowplayinghistory.App; 19 | import com.tanuj.nowplayinghistory.R; 20 | import com.tanuj.nowplayinghistory.Utils; 21 | import com.tanuj.nowplayinghistory.adapters.SongsPagedListAdapter; 22 | import com.tanuj.nowplayinghistory.callbacks.FavoritesItemTouchCallback; 23 | import com.tanuj.nowplayinghistory.callbacks.RecentsItemTouchCallback; 24 | import com.tanuj.nowplayinghistory.viewmodels.SongsListViewModel; 25 | 26 | public class ListFragment extends Fragment { 27 | 28 | private static final String EXTRA_SHOW_FAVORITES = "show_favorites"; 29 | private static final String EXTRA_MIN_TIMESTAMP = "min_timestamp"; 30 | 31 | private View emptyView; 32 | private RecyclerView recyclerView; 33 | private boolean showFavorites; 34 | private long minTimestamp; 35 | private SongsPagedListAdapter songsListAdapter; 36 | private Snackbar notificationAccessSnackbar; 37 | 38 | public static ListFragment newInstance(boolean showFavorites, long minTimestamp) { 39 | ListFragment fragment = new ListFragment(); 40 | 41 | Bundle args = new Bundle(); 42 | args.putBoolean(EXTRA_SHOW_FAVORITES, showFavorites); 43 | args.putLong(EXTRA_MIN_TIMESTAMP, minTimestamp); 44 | fragment.setArguments(args); 45 | 46 | return fragment; 47 | } 48 | 49 | @Override 50 | public void onCreate(@Nullable Bundle savedInstanceState) { 51 | super.onCreate(savedInstanceState); 52 | if (getArguments() != null) { 53 | showFavorites = getArguments().getBoolean(EXTRA_SHOW_FAVORITES); 54 | minTimestamp = getArguments().getLong(EXTRA_MIN_TIMESTAMP); 55 | } 56 | 57 | songsListAdapter = new SongsPagedListAdapter(); 58 | 59 | SongsListViewModel viewModel = ViewModelProviders.of(this).get(SongsListViewModel.class); 60 | viewModel.init(App.getDb().songDao(), minTimestamp, showFavorites); 61 | viewModel.getData().observe(this, songs -> { 62 | songsListAdapter.submitList(songs); 63 | setEmptyViewVisibility(Utils.isEmpty(songs)); 64 | }); 65 | } 66 | 67 | @Nullable 68 | @Override 69 | public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { 70 | notificationAccessSnackbar = Snackbar.make(container.findViewById(R.id.container), 71 | "Need notification access", Snackbar.LENGTH_INDEFINITE) 72 | .setAction("Grant access", v -> startActivity(new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))); 73 | 74 | View view = inflater.inflate(R.layout.list_songs, container, false); 75 | emptyView = view.findViewById(R.id.empty_view); 76 | recyclerView = view.findViewById(R.id.recycler_view); 77 | recyclerView.setHasFixedSize(true); 78 | recyclerView.setLayoutManager(new LinearLayoutManager(getContext()) { 79 | @Override 80 | public boolean supportsPredictiveItemAnimations() { 81 | return true; 82 | } 83 | }); 84 | recyclerView.setAdapter(songsListAdapter); 85 | initItemTouchHelper(recyclerView); 86 | return view; 87 | } 88 | 89 | @Override 90 | public void onPause() { 91 | super.onPause(); 92 | notificationAccessSnackbar.dismiss(); 93 | } 94 | 95 | @Override 96 | public void onResume() { 97 | super.onResume(); 98 | if (!Utils.isNotificationAccessGranted()) { 99 | notificationAccessSnackbar.show(); 100 | } 101 | } 102 | 103 | private void initItemTouchHelper(RecyclerView recyclerView) { 104 | ItemTouchHelper.SimpleCallback callback; 105 | if (showFavorites) { 106 | callback = new FavoritesItemTouchCallback(recyclerView, songsListAdapter); 107 | } else { 108 | callback = new RecentsItemTouchCallback(recyclerView, songsListAdapter); 109 | } 110 | new ItemTouchHelper(callback).attachToRecyclerView(recyclerView); 111 | } 112 | 113 | private void setEmptyViewVisibility(boolean visible) { 114 | if (emptyView != null) { 115 | emptyView.setVisibility(visible ? View.VISIBLE : View.INVISIBLE); 116 | } 117 | 118 | if (recyclerView != null) { 119 | recyclerView.setVisibility(visible ? View.INVISIBLE : View.VISIBLE); 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/fragments/MapFragment.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.fragments; 2 | 3 | import android.Manifest; 4 | import android.annotation.SuppressLint; 5 | import android.os.Bundle; 6 | import androidx.annotation.NonNull; 7 | import com.google.android.material.snackbar.Snackbar; 8 | import android.view.LayoutInflater; 9 | import android.view.View; 10 | import android.view.ViewGroup; 11 | 12 | import com.google.android.gms.maps.GoogleMap; 13 | import com.google.android.gms.maps.OnMapReadyCallback; 14 | import com.google.android.gms.maps.SupportMapFragment; 15 | import com.tanuj.nowplayinghistory.R; 16 | import com.tanuj.nowplayinghistory.Utils; 17 | import com.tanuj.nowplayinghistory.tasks.InitClusterManagerTask; 18 | 19 | public class MapFragment extends SupportMapFragment implements OnMapReadyCallback { 20 | 21 | private static final String EXTRA_SHOW_FAVORITES = "show_favorites"; 22 | private static final String EXTRA_MIN_TIMESTAMP = "min_timestamp"; 23 | 24 | private boolean showFavorites; 25 | private long minTimestamp; 26 | private Snackbar locationAccessSnackbar; 27 | private GoogleMap googleMap; 28 | private InitClusterManagerTask initClusterManagerTask; 29 | 30 | public static MapFragment newInstance(boolean showFavorites, long minTimestamp) { 31 | MapFragment fragment = new MapFragment(); 32 | 33 | Bundle args = new Bundle(); 34 | args.putBoolean(EXTRA_SHOW_FAVORITES, showFavorites); 35 | args.putLong(EXTRA_MIN_TIMESTAMP, minTimestamp); 36 | fragment.setArguments(args); 37 | 38 | return fragment; 39 | } 40 | 41 | @Override 42 | public void onCreate(Bundle bundle) { 43 | super.onCreate(bundle); 44 | if (getArguments() != null) { 45 | showFavorites = getArguments().getBoolean(EXTRA_SHOW_FAVORITES); 46 | minTimestamp = getArguments().getLong(EXTRA_MIN_TIMESTAMP); 47 | } 48 | 49 | getMapAsync(this); 50 | } 51 | 52 | @Override 53 | public View onCreateView(LayoutInflater layoutInflater, ViewGroup viewGroup, Bundle bundle) { 54 | locationAccessSnackbar = Snackbar.make(viewGroup.findViewById(R.id.container), "Need location access", Snackbar.LENGTH_INDEFINITE) 55 | .setAction("Grant access", v -> requestPermissions(new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, 0)); 56 | return super.onCreateView(layoutInflater, viewGroup, bundle); 57 | } 58 | 59 | @Override 60 | public void onDestroyView() { 61 | if (initClusterManagerTask != null) { 62 | initClusterManagerTask.finish(); 63 | } 64 | super.onDestroyView(); 65 | } 66 | 67 | @Override 68 | public void onPause() { 69 | super.onPause(); 70 | locationAccessSnackbar.dismiss(); 71 | } 72 | 73 | @Override 74 | public void onResume() { 75 | super.onResume(); 76 | if (!Utils.isLocationAccessGranted()) { 77 | locationAccessSnackbar.show(); 78 | } 79 | 80 | if (googleMap != null) { 81 | tryEnableLocationOnMap(googleMap); 82 | } 83 | } 84 | 85 | @Override 86 | public void onMapReady(GoogleMap map) { 87 | Utils.styleMap(getResources(), map); 88 | googleMap = map; 89 | tryEnableLocationOnMap(map); 90 | initClusterManagerTask = new InitClusterManagerTask(map, showFavorites, minTimestamp); 91 | initClusterManagerTask.setOnClusterClickListener(cluster -> { 92 | ClusterDialogFragment.newInstance(cluster).show(getFragmentManager(), "cluster-dialog"); 93 | return true; 94 | }); 95 | initClusterManagerTask.setOnClusterItemInfoWindowClickListener(mapItem -> Utils.launchMusicApp(mapItem.getTitle())); 96 | initClusterManagerTask.execute(); 97 | } 98 | 99 | @SuppressLint("MissingPermission") 100 | private void tryEnableLocationOnMap(@NonNull GoogleMap map) { 101 | if (Utils.isLocationAccessGranted()) { 102 | map.setMyLocationEnabled(true); 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/lastfm/LastFmGlideModule.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.lastfm; 2 | 3 | import android.content.Context; 4 | 5 | import com.bumptech.glide.Glide; 6 | import com.bumptech.glide.GlideBuilder; 7 | import com.bumptech.glide.Registry; 8 | import com.bumptech.glide.annotation.GlideModule; 9 | import com.bumptech.glide.load.DecodeFormat; 10 | import com.bumptech.glide.module.AppGlideModule; 11 | import com.bumptech.glide.request.RequestOptions; 12 | import com.tanuj.nowplayinghistory.persistence.Song; 13 | 14 | import java.io.InputStream; 15 | 16 | import androidx.annotation.NonNull; 17 | 18 | @GlideModule 19 | public class LastFmGlideModule extends AppGlideModule { 20 | 21 | @Override 22 | public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) { 23 | super.applyOptions(context, builder); 24 | builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_ARGB_8888)); 25 | } 26 | 27 | @Override 28 | public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) { 29 | registry.append(Song.class, InputStream.class, new LastFmSongLoader.Factory()); 30 | } 31 | 32 | // Disable manifest parsing to avoid adding similar modules twice. 33 | @Override 34 | public boolean isManifestParsingEnabled() { 35 | return false; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/lastfm/LastFmService.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.lastfm; 2 | 3 | import com.tanuj.nowplayinghistory.lastfm.pojos.TrackInfo; 4 | 5 | import retrofit2.Call; 6 | import retrofit2.http.GET; 7 | import retrofit2.http.Query; 8 | 9 | public interface LastFmService { 10 | 11 | @GET("/2.0/?method=track.getInfo&format=json") 12 | Call trackInfo(@Query("api_key") String key, @Query("artist") String artist, @Query("track") String track); 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/lastfm/LastFmSongFetcher.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.lastfm; 2 | 3 | import android.text.TextUtils; 4 | import android.util.Log; 5 | 6 | import com.bumptech.glide.Priority; 7 | import com.bumptech.glide.load.DataSource; 8 | import com.bumptech.glide.load.HttpException; 9 | import com.bumptech.glide.load.data.DataFetcher; 10 | import com.bumptech.glide.util.ContentLengthInputStream; 11 | import com.bumptech.glide.util.Preconditions; 12 | import com.tanuj.nowplayinghistory.App; 13 | import com.tanuj.nowplayinghistory.R; 14 | import com.tanuj.nowplayinghistory.Utils; 15 | import com.tanuj.nowplayinghistory.lastfm.pojos.Image; 16 | import com.tanuj.nowplayinghistory.lastfm.pojos.TrackInfo; 17 | import com.tanuj.nowplayinghistory.persistence.Song; 18 | 19 | import java.io.IOException; 20 | import java.io.InputStream; 21 | import java.util.List; 22 | 23 | import androidx.annotation.NonNull; 24 | import okhttp3.Call; 25 | import okhttp3.Callback; 26 | import okhttp3.Request; 27 | import okhttp3.Response; 28 | import okhttp3.ResponseBody; 29 | 30 | public class LastFmSongFetcher implements DataFetcher, Callback { 31 | private static final String TAG = "LastFmSongFetcher"; 32 | private final Call.Factory client; 33 | private final Song song; 34 | private InputStream stream; 35 | private ResponseBody responseBody; 36 | private DataCallback callback; 37 | // call may be accessed on the main thread while the object is in use on other threads. All other 38 | // accesses to variables may occur on different threads, but only one at a time. 39 | private volatile Call call; 40 | 41 | public LastFmSongFetcher(Call.Factory client, Song song) { 42 | this.client = client; 43 | this.song = song; 44 | } 45 | 46 | @Override 47 | public void loadData(@NonNull Priority priority, 48 | @NonNull final DataCallback callback) { 49 | String url = getImageUrl(song); 50 | Request.Builder requestBuilder = new Request.Builder().url(url); 51 | Request request = requestBuilder.build(); 52 | this.callback = callback; 53 | 54 | call = client.newCall(request); 55 | call.enqueue(this); 56 | } 57 | 58 | @Override 59 | public void onFailure(@NonNull Call call, @NonNull IOException e) { 60 | if (Log.isLoggable(TAG, Log.DEBUG)) { 61 | Log.d(TAG, "OkHttp failed to obtain result", e); 62 | } 63 | 64 | callback.onLoadFailed(e); 65 | } 66 | 67 | @Override 68 | public void onResponse(@NonNull Call call, @NonNull Response response) { 69 | responseBody = response.body(); 70 | if (response.isSuccessful()) { 71 | long contentLength = Preconditions.checkNotNull(responseBody).contentLength(); 72 | stream = ContentLengthInputStream.obtain(responseBody.byteStream(), contentLength); 73 | callback.onDataReady(stream); 74 | } else { 75 | callback.onLoadFailed(new HttpException(response.message(), response.code())); 76 | } 77 | } 78 | 79 | @Override 80 | public void cleanup() { 81 | try { 82 | if (stream != null) { 83 | stream.close(); 84 | } 85 | } catch (IOException e) { 86 | // Ignored 87 | } 88 | if (responseBody != null) { 89 | responseBody.close(); 90 | } 91 | callback = null; 92 | } 93 | 94 | @Override 95 | public void cancel() { 96 | Call local = call; 97 | if (local != null) { 98 | local.cancel(); 99 | } 100 | } 101 | 102 | @NonNull 103 | @Override 104 | public Class getDataClass() { 105 | return InputStream.class; 106 | } 107 | 108 | @NonNull 109 | @Override 110 | public DataSource getDataSource() { 111 | return DataSource.REMOTE; 112 | } 113 | 114 | private String getImageUrl(Song song) { 115 | String imageUrl = ""; 116 | 117 | try { 118 | String artist = Utils.extractArtistTitleFromText(song.getSongText()); 119 | String track = Utils.extractSongTitleFromText(song.getSongText()); 120 | 121 | retrofit2.Response response = App.getLastFmService().trackInfo(App.getContext().getString(R.string.lastfm_key), artist, track).execute(); 122 | 123 | if (response.isSuccessful()) { 124 | TrackInfo trackInfo = response.body(); 125 | 126 | if (trackInfo == null || 127 | trackInfo.getTrack() == null || 128 | trackInfo.getTrack().getAlbum() == null || 129 | trackInfo.getTrack().getAlbum().getImage() == null) { 130 | 131 | // Successful response by the API but no track/image found 132 | imageUrl = Utils.getPlaceholderImageUrl(song); 133 | } else { 134 | List images = trackInfo.getTrack().getAlbum().getImage(); 135 | for (Image image : images) { 136 | if (image.getSize().equals("large")) { 137 | imageUrl = image.getText(); 138 | } 139 | } 140 | 141 | // Or just use the last image url (hopefully the best quality) 142 | if (TextUtils.isEmpty(imageUrl) && images.size() > 0) { 143 | imageUrl = images.get(images.size() - 1).getText(); 144 | } 145 | 146 | // If still we don't have url just use the placeholder 147 | if (TextUtils.isEmpty(imageUrl)) { 148 | imageUrl = Utils.getPlaceholderImageUrl(song); 149 | } 150 | } 151 | } 152 | } catch (Exception e) { 153 | e.printStackTrace(); 154 | } 155 | 156 | return imageUrl; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/lastfm/LastFmSongLoader.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.lastfm; 2 | 3 | import com.bumptech.glide.load.Options; 4 | import com.bumptech.glide.load.model.ModelLoader; 5 | import com.bumptech.glide.load.model.ModelLoaderFactory; 6 | import com.bumptech.glide.load.model.MultiModelLoaderFactory; 7 | import com.tanuj.nowplayinghistory.persistence.Song; 8 | 9 | import java.io.InputStream; 10 | 11 | import androidx.annotation.NonNull; 12 | import okhttp3.Call; 13 | import okhttp3.OkHttpClient; 14 | 15 | public class LastFmSongLoader implements ModelLoader { 16 | 17 | private final Call.Factory client; 18 | 19 | public LastFmSongLoader(@NonNull Call.Factory client) { 20 | this.client = client; 21 | } 22 | 23 | @Override 24 | public boolean handles(@NonNull Song song) { 25 | return true; 26 | } 27 | 28 | @Override 29 | public LoadData buildLoadData(@NonNull Song model, int width, int height, 30 | @NonNull Options options) { 31 | return new LoadData<>(model, new LastFmSongFetcher(client, model)); 32 | } 33 | 34 | public static class Factory implements ModelLoaderFactory { 35 | private static volatile Call.Factory internalClient; 36 | private final Call.Factory client; 37 | 38 | private static Call.Factory getInternalClient() { 39 | if (internalClient == null) { 40 | synchronized (Factory.class) { 41 | if (internalClient == null) { 42 | internalClient = new OkHttpClient(); 43 | } 44 | } 45 | } 46 | return internalClient; 47 | } 48 | 49 | public Factory() { 50 | this(getInternalClient()); 51 | } 52 | 53 | public Factory(@NonNull Call.Factory client) { 54 | this.client = client; 55 | } 56 | 57 | @NonNull 58 | @Override 59 | public ModelLoader build(MultiModelLoaderFactory multiFactory) { 60 | return new LastFmSongLoader(client); 61 | } 62 | 63 | @Override 64 | public void teardown() { 65 | // Do nothing, this instance doesn't own the client. 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/lastfm/pojos/Album.java: -------------------------------------------------------------------------------- 1 | 2 | package com.tanuj.nowplayinghistory.lastfm.pojos; 3 | 4 | import java.util.List; 5 | import com.squareup.moshi.Json; 6 | 7 | public class Album { 8 | 9 | @Json(name = "artist") 10 | private String artist; 11 | @Json(name = "title") 12 | private String title; 13 | @Json(name = "mbid") 14 | private String mbid; 15 | @Json(name = "url") 16 | private String url; 17 | @Json(name = "image") 18 | private List image = null; 19 | @Json(name = "@attr") 20 | private Attr attr; 21 | 22 | public String getArtist() { 23 | return artist; 24 | } 25 | 26 | public void setArtist(String artist) { 27 | this.artist = artist; 28 | } 29 | 30 | public String getTitle() { 31 | return title; 32 | } 33 | 34 | public void setTitle(String title) { 35 | this.title = title; 36 | } 37 | 38 | public String getMbid() { 39 | return mbid; 40 | } 41 | 42 | public void setMbid(String mbid) { 43 | this.mbid = mbid; 44 | } 45 | 46 | public String getUrl() { 47 | return url; 48 | } 49 | 50 | public void setUrl(String url) { 51 | this.url = url; 52 | } 53 | 54 | public List getImage() { 55 | return image; 56 | } 57 | 58 | public void setImage(List image) { 59 | this.image = image; 60 | } 61 | 62 | public Attr getAttr() { 63 | return attr; 64 | } 65 | 66 | public void setAttr(Attr attr) { 67 | this.attr = attr; 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/lastfm/pojos/Artist.java: -------------------------------------------------------------------------------- 1 | 2 | package com.tanuj.nowplayinghistory.lastfm.pojos; 3 | 4 | import com.squareup.moshi.Json; 5 | 6 | public class Artist { 7 | 8 | @Json(name = "name") 9 | private String name; 10 | @Json(name = "mbid") 11 | private String mbid; 12 | @Json(name = "url") 13 | private String url; 14 | 15 | public String getName() { 16 | return name; 17 | } 18 | 19 | public void setName(String name) { 20 | this.name = name; 21 | } 22 | 23 | public String getMbid() { 24 | return mbid; 25 | } 26 | 27 | public void setMbid(String mbid) { 28 | this.mbid = mbid; 29 | } 30 | 31 | public String getUrl() { 32 | return url; 33 | } 34 | 35 | public void setUrl(String url) { 36 | this.url = url; 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/lastfm/pojos/Attr.java: -------------------------------------------------------------------------------- 1 | 2 | package com.tanuj.nowplayinghistory.lastfm.pojos; 3 | 4 | import com.squareup.moshi.Json; 5 | 6 | public class Attr { 7 | 8 | @Json(name = "position") 9 | private String position; 10 | 11 | public String getPosition() { 12 | return position; 13 | } 14 | 15 | public void setPosition(String position) { 16 | this.position = position; 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/lastfm/pojos/Example.java: -------------------------------------------------------------------------------- 1 | 2 | package com.tanuj.nowplayinghistory.lastfm.pojos; 3 | 4 | import com.squareup.moshi.Json; 5 | 6 | public class Example { 7 | 8 | @Json(name = "track") 9 | private Track track; 10 | 11 | public Track getTrack() { 12 | return track; 13 | } 14 | 15 | public void setTrack(Track track) { 16 | this.track = track; 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/lastfm/pojos/Image.java: -------------------------------------------------------------------------------- 1 | 2 | package com.tanuj.nowplayinghistory.lastfm.pojos; 3 | 4 | import com.squareup.moshi.Json; 5 | 6 | public class Image { 7 | 8 | @Json(name = "#text") 9 | private String text; 10 | @Json(name = "size") 11 | private String size; 12 | 13 | public String getText() { 14 | return text; 15 | } 16 | 17 | public void setText(String text) { 18 | this.text = text; 19 | } 20 | 21 | public String getSize() { 22 | return size; 23 | } 24 | 25 | public void setSize(String size) { 26 | this.size = size; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/lastfm/pojos/Streamable.java: -------------------------------------------------------------------------------- 1 | 2 | package com.tanuj.nowplayinghistory.lastfm.pojos; 3 | 4 | import com.squareup.moshi.Json; 5 | 6 | public class Streamable { 7 | 8 | @Json(name = "#text") 9 | private String text; 10 | @Json(name = "fulltrack") 11 | private String fulltrack; 12 | 13 | public String getText() { 14 | return text; 15 | } 16 | 17 | public void setText(String text) { 18 | this.text = text; 19 | } 20 | 21 | public String getFulltrack() { 22 | return fulltrack; 23 | } 24 | 25 | public void setFulltrack(String fulltrack) { 26 | this.fulltrack = fulltrack; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/lastfm/pojos/Tag.java: -------------------------------------------------------------------------------- 1 | 2 | package com.tanuj.nowplayinghistory.lastfm.pojos; 3 | 4 | import com.squareup.moshi.Json; 5 | 6 | public class Tag { 7 | 8 | @Json(name = "name") 9 | private String name; 10 | @Json(name = "url") 11 | private String url; 12 | 13 | public String getName() { 14 | return name; 15 | } 16 | 17 | public void setName(String name) { 18 | this.name = name; 19 | } 20 | 21 | public String getUrl() { 22 | return url; 23 | } 24 | 25 | public void setUrl(String url) { 26 | this.url = url; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/lastfm/pojos/Toptags.java: -------------------------------------------------------------------------------- 1 | 2 | package com.tanuj.nowplayinghistory.lastfm.pojos; 3 | 4 | import java.util.List; 5 | import com.squareup.moshi.Json; 6 | 7 | public class Toptags { 8 | 9 | @Json(name = "tag") 10 | private List tag = null; 11 | 12 | public List getTag() { 13 | return tag; 14 | } 15 | 16 | public void setTag(List tag) { 17 | this.tag = tag; 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/lastfm/pojos/Track.java: -------------------------------------------------------------------------------- 1 | 2 | package com.tanuj.nowplayinghistory.lastfm.pojos; 3 | 4 | import com.squareup.moshi.Json; 5 | 6 | public class Track { 7 | 8 | @Json(name = "name") 9 | private String name; 10 | @Json(name = "mbid") 11 | private String mbid; 12 | @Json(name = "url") 13 | private String url; 14 | @Json(name = "duration") 15 | private String duration; 16 | @Json(name = "streamable") 17 | private Streamable streamable; 18 | @Json(name = "listeners") 19 | private String listeners; 20 | @Json(name = "playcount") 21 | private String playcount; 22 | @Json(name = "artist") 23 | private Artist artist; 24 | @Json(name = "album") 25 | private Album album; 26 | @Json(name = "toptags") 27 | private Toptags toptags; 28 | @Json(name = "wiki") 29 | private Wiki wiki; 30 | 31 | public String getName() { 32 | return name; 33 | } 34 | 35 | public void setName(String name) { 36 | this.name = name; 37 | } 38 | 39 | public String getMbid() { 40 | return mbid; 41 | } 42 | 43 | public void setMbid(String mbid) { 44 | this.mbid = mbid; 45 | } 46 | 47 | public String getUrl() { 48 | return url; 49 | } 50 | 51 | public void setUrl(String url) { 52 | this.url = url; 53 | } 54 | 55 | public String getDuration() { 56 | return duration; 57 | } 58 | 59 | public void setDuration(String duration) { 60 | this.duration = duration; 61 | } 62 | 63 | public Streamable getStreamable() { 64 | return streamable; 65 | } 66 | 67 | public void setStreamable(Streamable streamable) { 68 | this.streamable = streamable; 69 | } 70 | 71 | public String getListeners() { 72 | return listeners; 73 | } 74 | 75 | public void setListeners(String listeners) { 76 | this.listeners = listeners; 77 | } 78 | 79 | public String getPlaycount() { 80 | return playcount; 81 | } 82 | 83 | public void setPlaycount(String playcount) { 84 | this.playcount = playcount; 85 | } 86 | 87 | public Artist getArtist() { 88 | return artist; 89 | } 90 | 91 | public void setArtist(Artist artist) { 92 | this.artist = artist; 93 | } 94 | 95 | public Album getAlbum() { 96 | return album; 97 | } 98 | 99 | public void setAlbum(Album album) { 100 | this.album = album; 101 | } 102 | 103 | public Toptags getToptags() { 104 | return toptags; 105 | } 106 | 107 | public void setToptags(Toptags toptags) { 108 | this.toptags = toptags; 109 | } 110 | 111 | public Wiki getWiki() { 112 | return wiki; 113 | } 114 | 115 | public void setWiki(Wiki wiki) { 116 | this.wiki = wiki; 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/lastfm/pojos/TrackInfo.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.lastfm.pojos; 2 | 3 | import com.squareup.moshi.Json; 4 | 5 | public class TrackInfo { 6 | 7 | @Json(name = "track") 8 | private Track track; 9 | 10 | public Track getTrack() { 11 | return track; 12 | } 13 | 14 | public void setTrack(Track track) { 15 | this.track = track; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/lastfm/pojos/Wiki.java: -------------------------------------------------------------------------------- 1 | 2 | package com.tanuj.nowplayinghistory.lastfm.pojos; 3 | 4 | import com.squareup.moshi.Json; 5 | 6 | public class Wiki { 7 | 8 | @Json(name = "published") 9 | private String published; 10 | @Json(name = "summary") 11 | private String summary; 12 | @Json(name = "content") 13 | private String content; 14 | 15 | public String getPublished() { 16 | return published; 17 | } 18 | 19 | public void setPublished(String published) { 20 | this.published = published; 21 | } 22 | 23 | public String getSummary() { 24 | return summary; 25 | } 26 | 27 | public void setSummary(String summary) { 28 | this.summary = summary; 29 | } 30 | 31 | public String getContent() { 32 | return content; 33 | } 34 | 35 | public void setContent(String content) { 36 | this.content = content; 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/persistence/AppDatabase.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.persistence; 2 | 3 | import androidx.room.Database; 4 | import androidx.room.RoomDatabase; 5 | 6 | @Database(entities = {Song.class, FavSong.class}, version = 5, exportSchema = false) 7 | public abstract class AppDatabase extends RoomDatabase { 8 | 9 | public abstract SongDao songDao(); 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/persistence/FavSong.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.persistence; 2 | 3 | import androidx.room.Entity; 4 | 5 | @Entity(tableName = "fav_songs") 6 | public class FavSong extends Song { 7 | public FavSong(Song song) { 8 | this(song.getTimestamp(), song.getSongText()); 9 | setLat(song.getLat()); 10 | setLon(song.getLon()); 11 | } 12 | 13 | public FavSong(long timestamp, String songText) { 14 | super(timestamp, songText); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/persistence/Song.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.persistence; 2 | 3 | import android.os.Parcel; 4 | import android.os.Parcelable; 5 | 6 | import com.bumptech.glide.load.Key; 7 | import com.tanuj.nowplayinghistory.Utils; 8 | 9 | import java.security.MessageDigest; 10 | 11 | import androidx.annotation.NonNull; 12 | import androidx.room.Entity; 13 | import androidx.room.Ignore; 14 | import androidx.room.PrimaryKey; 15 | 16 | @Entity(tableName = "songs") 17 | public class Song implements Parcelable, Key { 18 | @PrimaryKey 19 | private long timestamp; 20 | private String songText; 21 | private double lat = -1; 22 | private double lon = -1; 23 | 24 | @Ignore 25 | private volatile byte[] cacheKeyBytes; 26 | 27 | public Song(long timestamp, String songText) { 28 | this.timestamp = timestamp; 29 | this.songText = songText; 30 | } 31 | 32 | protected Song(Parcel in) { 33 | timestamp = in.readLong(); 34 | songText = in.readString(); 35 | lat = in.readDouble(); 36 | lon = in.readDouble(); 37 | } 38 | 39 | public static final Creator CREATOR = new Creator() { 40 | @Override 41 | public Song createFromParcel(Parcel in) { 42 | return new Song(in); 43 | } 44 | 45 | @Override 46 | public Song[] newArray(int size) { 47 | return new Song[size]; 48 | } 49 | }; 50 | 51 | public long getTimestamp() { 52 | return timestamp; 53 | } 54 | 55 | public String getTimestampText() { 56 | return Utils.getTimestampString(timestamp); 57 | } 58 | 59 | public String getSongText() { 60 | return songText; 61 | } 62 | 63 | public String getSongTitle() { 64 | return Utils.extractSongTitleFromText(songText); 65 | } 66 | 67 | public String getArtistTitle() { 68 | return Utils.extractArtistTitleFromText(songText); 69 | } 70 | 71 | public double getLat() { 72 | return lat; 73 | } 74 | 75 | public void setLat(double lat) { 76 | this.lat = lat; 77 | } 78 | 79 | public double getLon() { 80 | return lon; 81 | } 82 | 83 | public void setLon(double lon) { 84 | this.lon = lon; 85 | } 86 | 87 | @Override 88 | public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { 89 | messageDigest.update(getCacheKeyBytes()); 90 | } 91 | 92 | private byte[] getCacheKeyBytes() { 93 | if (cacheKeyBytes == null) { 94 | cacheKeyBytes = songText.getBytes(CHARSET); 95 | } 96 | return cacheKeyBytes; 97 | } 98 | 99 | @Override 100 | public boolean equals(Object o) { 101 | if (this == o) return true; 102 | if (o == null || getClass() != o.getClass()) return false; 103 | 104 | Song song = (Song) o; 105 | 106 | return songText.equals(song.songText); 107 | } 108 | 109 | @Override 110 | public int hashCode() { 111 | return songText.hashCode(); 112 | } 113 | 114 | @Override 115 | public int describeContents() { 116 | return 0; 117 | } 118 | 119 | @Override 120 | public void writeToParcel(Parcel dest, int flags) { 121 | dest.writeLong(timestamp); 122 | dest.writeString(songText); 123 | dest.writeDouble(lat); 124 | dest.writeDouble(lon); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/persistence/SongDao.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.persistence; 2 | 3 | import androidx.lifecycle.LiveData; 4 | import androidx.paging.DataSource; 5 | import androidx.room.Dao; 6 | import androidx.room.Delete; 7 | import androidx.room.Insert; 8 | import androidx.room.Query; 9 | 10 | import java.util.List; 11 | 12 | // Manages db operations for Song and FavSong 13 | 14 | @Dao 15 | public interface SongDao { 16 | 17 | // Song 18 | @Insert 19 | public void insert(Song... songs); 20 | 21 | @Delete 22 | public void delete(Song... songs); 23 | 24 | @Query("DELETE FROM songs WHERE timestamp < :maxTimestamp") 25 | public void deleteAllSongs(long maxTimestamp); 26 | 27 | @Query("SELECT * FROM songs WHERE timestamp > :minTimestamp ORDER BY timestamp DESC") 28 | public DataSource.Factory loadAllSongs(long minTimestamp); 29 | 30 | @Query("SELECT * FROM songs WHERE (lat BETWEEN :minLat AND :maxLat) AND ((lon BETWEEN :minLon1 AND :maxLon1) OR (lon BETWEEN :minLon2 AND :maxLon2)) AND (timestamp > :minTimestamp) ORDER BY timestamp DESC") 31 | public LiveData> loadAllSongs(double minLat, double maxLat, double minLon1, double maxLon1, double minLon2, double maxLon2, long minTimestamp); 32 | 33 | @Query("SELECT * FROM songs WHERE timestamp = (SELECT MAX(timestamp) FROM songs)") 34 | public Song loadLatestSong(); 35 | 36 | // FavSong 37 | @Insert 38 | public void insert(FavSong... favSongs); 39 | 40 | @Delete 41 | public void delete(FavSong... favSongs); 42 | 43 | @Query("DELETE FROM fav_songs WHERE timestamp < :maxTimestamp") 44 | public void deleteAllFavSongs(long maxTimestamp); 45 | 46 | @Query("SELECT * FROM fav_songs WHERE timestamp > :minTimestamp ORDER BY timestamp DESC") 47 | public DataSource.Factory loadAllFavSongs(long minTimestamp); 48 | 49 | @Query("SELECT * FROM fav_songs WHERE (lat BETWEEN :minLat AND :maxLat) AND ((lon BETWEEN :minLon1 AND :maxLon1) OR (lon BETWEEN :minLon2 AND :maxLon2)) AND (timestamp > :minTimestamp) ORDER BY timestamp DESC") 50 | public LiveData> loadAllFavSongs(double minLat, double maxLat, double minLon1, double maxLon1, double minLon2, double maxLon2, long minTimestamp); 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/tasks/InitClusterManagerTask.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.tasks; 2 | 3 | import androidx.lifecycle.LiveData; 4 | import androidx.lifecycle.Observer; 5 | import android.location.Location; 6 | import android.os.AsyncTask; 7 | import androidx.annotation.Nullable; 8 | 9 | import com.google.android.gms.maps.CameraUpdateFactory; 10 | import com.google.android.gms.maps.GoogleMap; 11 | import com.google.android.gms.maps.model.LatLng; 12 | import com.google.android.gms.maps.model.LatLngBounds; 13 | import com.google.maps.android.clustering.ClusterManager; 14 | import com.tanuj.nowplayinghistory.App; 15 | import com.tanuj.nowplayinghistory.MapItem; 16 | import com.tanuj.nowplayinghistory.Utils; 17 | import com.tanuj.nowplayinghistory.persistence.Song; 18 | import com.tanuj.nowplayinghistory.persistence.SongDao; 19 | 20 | import java.util.List; 21 | 22 | public class InitClusterManagerTask extends AsyncTask implements GoogleMap.OnCameraIdleListener, Observer> { 23 | 24 | private final GoogleMap map; 25 | private final boolean showFavorites; 26 | private final long minTimestamp; 27 | private final ClusterManager clusterManager; 28 | private LatLngBounds prevLatLngBounds; 29 | private LiveData> prevData; 30 | private boolean skipReload; 31 | 32 | public InitClusterManagerTask(GoogleMap map, boolean showFavorites, long minTimestamp) { 33 | this.map = map; 34 | this.showFavorites = showFavorites; 35 | this.minTimestamp = minTimestamp; 36 | this.clusterManager = new ClusterManager<>(App.getContext(), map); 37 | 38 | clusterManager.setAnimation(false); 39 | clusterManager.setOnClusterItemClickListener(mapItem -> { 40 | skipReload = true; 41 | return false; 42 | }); 43 | } 44 | 45 | public void setOnClusterItemInfoWindowClickListener(ClusterManager.OnClusterItemInfoWindowClickListener listener) { 46 | clusterManager.setOnClusterItemInfoWindowClickListener(listener); 47 | } 48 | 49 | public void setOnClusterClickListener(ClusterManager.OnClusterClickListener listener) { 50 | clusterManager.setOnClusterClickListener(listener); 51 | } 52 | 53 | @Override 54 | protected Location doInBackground(Void... voids) { 55 | return Utils.getCurrentLocation(); 56 | } 57 | 58 | @Override 59 | protected void onPostExecute(Location location) { 60 | if (location != null) { 61 | map.moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(location.getLatitude(), location.getLongitude()), 14)); 62 | } 63 | 64 | map.setOnMarkerClickListener(clusterManager); 65 | map.setOnInfoWindowClickListener(clusterManager); 66 | map.setOnCameraIdleListener(this); 67 | } 68 | 69 | public void finish() { 70 | cancel(true); 71 | clusterManager.setOnClusterItemInfoWindowClickListener(null); 72 | clusterManager.setOnClusterClickListener(null); 73 | map.setOnMarkerClickListener(null); 74 | map.setOnInfoWindowClickListener(null); 75 | map.setOnCameraIdleListener(null); 76 | if (prevData != null) { 77 | prevData.removeObserver(this); 78 | } 79 | } 80 | 81 | private void reloadData(LatLngBounds latLngBounds) { 82 | if (prevData != null) { 83 | prevData.removeObserver(this); 84 | } 85 | prevData = getData(App.getDb().songDao(), latLngBounds, minTimestamp, showFavorites); 86 | prevData.observeForever(this); 87 | } 88 | 89 | @Override 90 | public void onCameraIdle() { 91 | if (skipReload) { 92 | skipReload = false; 93 | return; 94 | } 95 | 96 | LatLngBounds latLngBounds = map.getProjection().getVisibleRegion().latLngBounds; 97 | if (prevLatLngBounds == null || !prevLatLngBounds.equals(latLngBounds)) { 98 | prevLatLngBounds = latLngBounds; 99 | reloadData(latLngBounds); 100 | } 101 | } 102 | 103 | @Override 104 | public void onChanged(@Nullable List songs) { 105 | clusterManager.clearItems(); 106 | for (Song song : songs) { 107 | if (song.getLat() == -1 && song.getLon() == -1) { 108 | // Location not available 109 | continue; 110 | } 111 | clusterManager.addItem(new MapItem(song)); 112 | } 113 | clusterManager.cluster(); 114 | } 115 | 116 | public LiveData> getData(SongDao songDao, LatLngBounds bounds, long minTimestamp, boolean favs) { 117 | // [southwest.latitude, northeast.latitude] 118 | double minLat = bounds.southwest.latitude; 119 | double maxLat = bounds.northeast.latitude; 120 | 121 | double minLon1; 122 | double maxLon1; 123 | double minLon2; 124 | double maxLon2; 125 | if (bounds.southwest.longitude <= bounds.northeast.longitude) { 126 | // [southwest.longitude, northeast.longitude] 127 | minLon1 = bounds.southwest.longitude; 128 | maxLon1 = bounds.northeast.longitude; 129 | minLon2 = 0; 130 | maxLon2 = 0; 131 | } else { 132 | // [southwest.longitude, 180) ∪ [-180, northeast.longitude] 133 | minLon1 = bounds.southwest.longitude; 134 | maxLon1 = 180; 135 | minLon2 = -180; 136 | maxLon2 = bounds.northeast.longitude; 137 | } 138 | 139 | if (favs) { 140 | return songDao.loadAllFavSongs(minLat, maxLat, minLon1, maxLon1, minLon2, maxLon2, minTimestamp); 141 | } else { 142 | return songDao.loadAllSongs(minLat, maxLat, minLon1, maxLon1, minLon2, maxLon2, minTimestamp); 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/viewmodels/SongsListViewModel.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.viewmodels; 2 | 3 | import androidx.lifecycle.LiveData; 4 | import androidx.lifecycle.ViewModel; 5 | import androidx.paging.LivePagedListBuilder; 6 | import androidx.paging.PagedList; 7 | 8 | import com.tanuj.nowplayinghistory.persistence.Song; 9 | import com.tanuj.nowplayinghistory.persistence.SongDao; 10 | 11 | public class SongsListViewModel extends ViewModel { 12 | private LiveData> data; 13 | 14 | public void init(SongDao songDao, long minTimestamp, boolean favs) { 15 | if (favs) { 16 | data = new LivePagedListBuilder<>(songDao.loadAllFavSongs(minTimestamp), 20).build(); 17 | } else { 18 | data = new LivePagedListBuilder<>(songDao.loadAllSongs(minTimestamp), 20).build(); 19 | } 20 | } 21 | 22 | public LiveData> getData() { 23 | return data; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/tanuj/nowplayinghistory/views/RateMeView.java: -------------------------------------------------------------------------------- 1 | package com.tanuj.nowplayinghistory.views; 2 | 3 | import android.content.Context; 4 | import android.content.SharedPreferences; 5 | import android.preference.PreferenceManager; 6 | import androidx.annotation.Nullable; 7 | import androidx.appcompat.app.AlertDialog; 8 | 9 | import android.util.AttributeSet; 10 | import android.view.LayoutInflater; 11 | import android.view.MotionEvent; 12 | import android.view.View; 13 | import android.widget.FrameLayout; 14 | 15 | import com.tanuj.nowplayinghistory.R; 16 | import com.tanuj.nowplayinghistory.Utils; 17 | 18 | public class RateMeView extends FrameLayout implements View.OnClickListener { 19 | 20 | private static final String PREF_NEVER_SHOW = "rate_me_never_show"; 21 | private static final String PREF_LAUNCH_COUNT = "rate_me_launch_count"; 22 | private static final String PREF_DEFER_COUNT = "rate_me_defer_count"; 23 | private static final int MIN_LAUNCH_COUNT_THRESHOLD = 4; 24 | 25 | private SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getContext()); 26 | private boolean dismissed = false; 27 | 28 | public RateMeView(Context context) { 29 | this(context, null); 30 | } 31 | 32 | public RateMeView(Context context, @Nullable AttributeSet attrs) { 33 | this(context, attrs, 0); 34 | } 35 | 36 | public RateMeView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { 37 | this(context, attrs, defStyleAttr, 0); 38 | } 39 | 40 | public RateMeView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { 41 | super(context, attrs, defStyleAttr, defStyleRes); 42 | } 43 | 44 | @Override 45 | protected void onAttachedToWindow() { 46 | super.onAttachedToWindow(); 47 | 48 | incrementLaunchCount(); 49 | if (shouldShow()) { 50 | View view = LayoutInflater.from(getContext()).inflate(R.layout.rate_me, this); 51 | view.findViewById(R.id.no).setOnClickListener(this); 52 | view.findViewById(R.id.yes).setOnClickListener(this); 53 | } else { 54 | dismissed = true; 55 | } 56 | } 57 | 58 | @Override 59 | protected void onDetachedFromWindow() { 60 | super.onDetachedFromWindow(); 61 | 62 | if (!dismissed) { 63 | incrementDeferredCount(); 64 | } 65 | } 66 | 67 | @Override 68 | public boolean onTouchEvent(MotionEvent event) { 69 | return true; 70 | } 71 | 72 | @Override 73 | public void onClick(View v) { 74 | switch (v.getId()) { 75 | case R.id.no: 76 | onNoClicked(); 77 | break; 78 | case R.id.yes: 79 | onYesClicked(); 80 | break; 81 | } 82 | } 83 | 84 | private void onNoClicked() { 85 | new AlertDialog.Builder(getContext()).setTitle("Send feedback email?") 86 | .setMessage("Let us know if something's not working as expected. You can even request new features!") 87 | .setNegativeButton("No", (dialog, which) -> { 88 | hideAndDeferShow(); 89 | dialog.dismiss(); 90 | }) 91 | .setPositiveButton("Yes", (dialog, which) -> { 92 | Utils.composeFeedbackEmail(); 93 | hideAndDeferShow(); 94 | dialog.dismiss(); 95 | }).show(); 96 | } 97 | 98 | private void onYesClicked() { 99 | new AlertDialog.Builder(getContext()).setTitle("Rate us on Google Play?") 100 | .setMessage("You're awesome! Thanks for trying out the app.") 101 | .setNegativeButton("Not now", (dialog, which) -> { 102 | hideAndDeferShow(); 103 | dialog.dismiss(); 104 | }) 105 | .setPositiveButton("Sure", (dialog, which) -> { 106 | Utils.openPlayStore(); 107 | hideAndNeverShow(); 108 | dialog.dismiss(); 109 | }).show(); 110 | } 111 | 112 | private boolean shouldShow() { 113 | boolean neverShow = sharedPreferences.getBoolean(PREF_NEVER_SHOW, false); 114 | if (!neverShow) { 115 | int launchCount = sharedPreferences.getInt(PREF_LAUNCH_COUNT, 0); 116 | int deferCount = sharedPreferences.getInt(PREF_DEFER_COUNT, 0); 117 | return MIN_LAUNCH_COUNT_THRESHOLD * Math.pow(2, deferCount) == launchCount; 118 | } 119 | return false; 120 | } 121 | 122 | private void hideAndDeferShow() { 123 | removeAllViews(); 124 | dismissed = true; 125 | incrementDeferredCount(); 126 | } 127 | 128 | private void hideAndNeverShow() { 129 | removeAllViews(); 130 | dismissed = true; 131 | sharedPreferences.edit().putBoolean(PREF_NEVER_SHOW, true).apply(); 132 | } 133 | 134 | private void incrementLaunchCount() { 135 | int launchCount = sharedPreferences.getInt(PREF_LAUNCH_COUNT, 0); 136 | sharedPreferences.edit().putInt(PREF_LAUNCH_COUNT, launchCount + 1).apply(); 137 | } 138 | 139 | private void incrementDeferredCount() { 140 | int deferCount = sharedPreferences.getInt(PREF_DEFER_COUNT, 0); 141 | sharedPreferences.edit().putInt(PREF_DEFER_COUNT, deferCount + 1).apply(); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /app/src/main/promo-material/feature graphic/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitanuj/nowplayinghistory/2232ac2a0272a30ef02f5096a78906d9b9897e59/app/src/main/promo-material/feature graphic/banner.png -------------------------------------------------------------------------------- /app/src/main/promo-material/feature graphic/banner.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitanuj/nowplayinghistory/2232ac2a0272a30ef02f5096a78906d9b9897e59/app/src/main/promo-material/feature graphic/banner.pptx -------------------------------------------------------------------------------- /app/src/main/promo-material/ic_launcher-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitanuj/nowplayinghistory/2232ac2a0272a30ef02f5096a78906d9b9897e59/app/src/main/promo-material/ic_launcher-web.png -------------------------------------------------------------------------------- /app/src/main/promo-material/screenshots/Screenshot_20180708-214244.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitanuj/nowplayinghistory/2232ac2a0272a30ef02f5096a78906d9b9897e59/app/src/main/promo-material/screenshots/Screenshot_20180708-214244.png -------------------------------------------------------------------------------- /app/src/main/promo-material/screenshots/Screenshot_20180708-215514.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitanuj/nowplayinghistory/2232ac2a0272a30ef02f5096a78906d9b9897e59/app/src/main/promo-material/screenshots/Screenshot_20180708-215514.png -------------------------------------------------------------------------------- /app/src/main/promo-material/screenshots/Screenshot_20180726-212808.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitanuj/nowplayinghistory/2232ac2a0272a30ef02f5096a78906d9b9897e59/app/src/main/promo-material/screenshots/Screenshot_20180726-212808.png -------------------------------------------------------------------------------- /app/src/main/promo-material/screenshots/Screenshot_20180726-213151.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitanuj/nowplayinghistory/2232ac2a0272a30ef02f5096a78906d9b9897e59/app/src/main/promo-material/screenshots/Screenshot_20180726-213151.png -------------------------------------------------------------------------------- /app/src/main/promo-material/screenshots/Screenshot_20180728-182753.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitanuj/nowplayinghistory/2232ac2a0272a30ef02f5096a78906d9b9897e59/app/src/main/promo-material/screenshots/Screenshot_20180728-182753.png -------------------------------------------------------------------------------- /app/src/main/promo-material/screenshots/Screenshot_20180728-182805.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitanuj/nowplayinghistory/2232ac2a0272a30ef02f5096a78906d9b9897e59/app/src/main/promo-material/screenshots/Screenshot_20180728-182805.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/empty_image.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_album.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 12 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_delete.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_favorites.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_filter.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_list.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_map.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_recent.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/slide_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitanuj/nowplayinghistory/2232ac2a0272a30ef02f5096a78906d9b9897e59/app/src/main/res/drawable/slide_1.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/slide_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitanuj/nowplayinghistory/2232ac2a0272a30ef02f5096a78906d9b9897e59/app/src/main/res/drawable/slide_2.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/slide_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitanuj/nowplayinghistory/2232ac2a0272a30ef02f5096a78906d9b9897e59/app/src/main/res/drawable/slide_3.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/slide_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitanuj/nowplayinghistory/2232ac2a0272a30ef02f5096a78906d9b9897e59/app/src/main/res/drawable/slide_4.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/splash_screen_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/layout-land/cluster_dialog.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 17 | 18 | 23 | 24 | -------------------------------------------------------------------------------- /app/src/main/res/layout-land/empty_state.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 15 | 16 | 23 | 24 | 32 | 33 | 40 | 41 |