diff --git a/README.md b/README.md index ae7eb67d..52420d24 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,17 @@ This is a native music player for Android devices that connects to Jellyfin medi * Playback history reporting * Filter content by library +# Requisites +- A Jellfin server. See how to setup one [here](https://jellyfin.org/docs/general/quick-start/). +- Android 4.4 or later + # Issues Since this was a small project intended mainly for myself, there are some things I haven't resolved yet. I would appreciate pull requests to fix any of these issues! * Artist sorting isn't available through the API -* Playlists and favorites will not update automatically when changed +* Playlists and favorites will not update automatically when changed ([#5](https://github.com/adrianvic/jamfish/issues/5)) +* App may crash on really low end devices due exceeding the maximum bitmap memory ([#4](https://github.com/adrianvic/jamfish/issues/4)) # Future Plans diff --git a/app/build.gradle b/app/build.gradle index 30aa7bdf..534ad6d3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,10 +10,12 @@ android { minSdk 19 targetSdk 33 - versionCode 1 - versionName '1.4.0' + versionCode 2 + versionName '1.4.1' + // for SDK < 19 multiDexEnabled true + vectorDrawables { useSupportLibrary true } @@ -105,8 +107,9 @@ dependencies { implementation 'com.android.support:multidex:1.0.3' implementation 'com.mlegy.redscreenofdeath:red-screen-of-death:0.1.3' - implementation 'com.squareup.retrofit2:retrofit:2.9.0' - implementation 'com.squareup.retrofit2:converter-gson:2.9.0' + // old version of retrofit to work with api < 21 + implementation 'com.squareup.retrofit2:retrofit:2.6.4' + implementation 'com.squareup.retrofit2:converter-gson:2.6.0' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' @@ -117,4 +120,10 @@ dependencies { implementation 'com.github.bumptech.glide:annotations:4.12.0' implementation 'com.github.bumptech.glide:glide:4.12.0' implementation 'com.github.bumptech.glide:okhttp3-integration:4.12.0' + + // for supporting legacy android versions: + implementation('com.squareup.okhttp3:okhttp:3.12.13') + + implementation "androidx.multidex:multidex:2.0.1" + implementation 'org.conscrypt:conscrypt-android:2.5.2' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2ad3febf..e5ab2f2f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,9 @@ + + @@ -49,6 +51,7 @@ + @@ -64,7 +67,7 @@ android:name="org.adrianvictor.geleia.views.shortcuts.AppShortcutLauncherActivity" android:launchMode="singleInstance" android:theme="@android:style/Theme.Translucent.NoTitleBar" /> - + @@ -136,6 +139,12 @@ android:resource="@xml/provider_paths" /> + + diff --git a/app/src/main/java/org/adrianvictor/geleia/App.java b/app/src/main/java/org/adrianvictor/geleia/App.java index 3145450b..231cee4c 100644 --- a/app/src/main/java/org/adrianvictor/geleia/App.java +++ b/app/src/main/java/org/adrianvictor/geleia/App.java @@ -6,6 +6,9 @@ import android.content.Context; import android.os.Build; import android.provider.Settings; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.multidex.MultiDexApplication; import androidx.room.Room; import org.adrianvictor.geleia.database.JellyDatabase; @@ -14,6 +17,7 @@ import org.adrianvictor.geleia.util.PreferenceUtil; import org.adrianvictor.geleia.views.shortcuts.DynamicShortcutManager; import com.melegy.redscreenofdeath.RedScreenOfDeath; +import org.conscrypt.Conscrypt; import org.jellyfin.apiclient.interaction.AndroidDevice; import org.jellyfin.apiclient.interaction.ApiClient; import org.jellyfin.apiclient.interaction.VolleyHttpClient; @@ -22,7 +26,16 @@ import org.jellyfin.apiclient.interaction.http.IAsyncHttpClient; import org.jellyfin.apiclient.logging.AndroidLogger; import org.jellyfin.apiclient.logging.ILogger; -public class App extends Application { +import com.bumptech.glide.Glide; +import com.bumptech.glide.Registry; +import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader; +import com.bumptech.glide.load.model.GlideUrl; +import java.io.InputStream; +import okhttp3.OkHttpClient; + +import java.security.Security; + +public class App extends MultiDexApplication { private static App app; private static JellyDatabase database; @@ -30,8 +43,14 @@ public class App extends Application { @Override public void onCreate() { + // Initializing stuff for older Android APIs compatibility + Security.insertProviderAt(Conscrypt.newProvider(), 1); // To have SSL 1.2 on API < 19 + AppCompatDelegate.setCompatVectorFromResourcesEnabled(true); // To load vectors on API < 19 + super.onCreate(); + initializeGlide(this); + if (BuildConfig.DEBUG) { RedScreenOfDeath.init(this); } @@ -40,7 +59,7 @@ public class App extends Application { database = createDatabase(this); apiClient = createApiClient(this); - if (database.userDao().getUsers().size() == 0) { + if (database.userDao().getUsers().isEmpty()) { PreferenceUtil.getInstance(this).setServer(null); PreferenceUtil.getInstance(this).setUser(null); } @@ -76,6 +95,7 @@ public class App extends Application { IDevice device = new AndroidDevice(deviceId, deviceName); EventListener eventListener = new EventListener(); + return new ApiClient(httpClient, logger, server, appName, appVersion, device, eventListener); } @@ -90,4 +110,15 @@ public class App extends Application { public static App getInstance() { return app; } + + private void initializeGlide(@NonNull Context context) { + // This OkHttpClient is now created with Conscrypt as the SSL provider. + OkHttpClient client = new OkHttpClient.Builder().build(); + + // Manually create and register Glide's OkHttp component. + OkHttpUrlLoader.Factory factory = new OkHttpUrlLoader.Factory(client); + + // Ensure Glide is initialized and then register the component. + Glide.get(context).getRegistry().replace(GlideUrl.class, InputStream.class, factory); + } } diff --git a/app/src/main/java/org/adrianvictor/geleia/activities/AboutActivity.java b/app/src/main/java/org/adrianvictor/geleia/activities/AboutActivity.java index 50601fac..56ffa5cf 100644 --- a/app/src/main/java/org/adrianvictor/geleia/activities/AboutActivity.java +++ b/app/src/main/java/org/adrianvictor/geleia/activities/AboutActivity.java @@ -7,6 +7,7 @@ import android.view.View; import androidx.annotation.NonNull; +import org.adrianvictor.geleia.service.notifications.ErrorNotification; import org.adrianvictor.geleia.util.NavigationUtil; import org.adrianvictor.geleia.util.PreferenceUtil; import org.adrianvictor.geleia.databinding.ActivityAboutBinding; @@ -102,7 +103,7 @@ public class AboutActivity extends AbsBaseActivity implements View.OnClickListen try { return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName; } catch (PackageManager.NameNotFoundException e) { - e.printStackTrace(); + ErrorNotification.show(context, e.getMessage()); } return "Unknown"; diff --git a/app/src/main/java/org/adrianvictor/geleia/activities/DirectoryPickerActivity.java b/app/src/main/java/org/adrianvictor/geleia/activities/DirectoryPickerActivity.java new file mode 100644 index 00000000..4452596d --- /dev/null +++ b/app/src/main/java/org/adrianvictor/geleia/activities/DirectoryPickerActivity.java @@ -0,0 +1,127 @@ +package org.adrianvictor.geleia.activities; + +import android.content.Intent; +import android.os.Bundle; +import android.os.Environment; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.TextView; + +import androidx.appcompat.app.AppCompatActivity; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; + +public class DirectoryPickerActivity extends AppCompatActivity { + + public static final String EXTRA_RESULT_PATH = "result_path"; + + private File rootDir; + private File currentDir; + + private ArrayAdapter adapter; + private TextView pathView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + rootDir = Environment.getExternalStorageDirectory(); + currentDir = rootDir; + + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.VERTICAL); + + pathView = new TextView(this); + pathView.setPadding(24, 24, 24, 24); + + ListView listView = new ListView(this); + + Button selectButton = new Button(this); + selectButton.setText("Select this folder"); + + layout.addView(pathView); + layout.addView(listView, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + layout.addView(selectButton); + + setContentView(layout); + + adapter = new ArrayAdapter(this, + android.R.layout.simple_list_item_1, new ArrayList<>()) { + @Override + public View getView(int position, View convertView, ViewGroup parent) { + TextView tv = (TextView) super.getView(position, convertView, parent); + File f = getItem(position); + + File parentFile = currentDir.getParentFile(); + + if (parentFile != null && parentFile.equals(f)) { + tv.setText(".."); + } else { + tv.setText(f.getName()); + } + + return tv; + } + }; + + listView.setAdapter(adapter); + + listView.setOnItemClickListener((p, v, pos, id) -> { + File dir = adapter.getItem(pos); + if (dir != null) openDir(dir); + }); + + selectButton.setOnClickListener(v -> { + Intent result = new Intent(); + result.putExtra(EXTRA_RESULT_PATH, currentDir.getAbsolutePath()); + setResult(RESULT_OK, result); + finish(); + }); + + openDir(rootDir); + } + + private void openDir(File dir) { + if (!dir.exists() || !dir.canRead()) return; + + File[] dirs = dir.listFiles(f -> + f.isDirectory() && f.canRead() + ); + + if (dirs == null) return; + + adapter.clear(); + + File parent = dir.getParentFile(); + if (parent != null && parent.getAbsolutePath().startsWith(rootDir.getAbsolutePath())) { + adapter.add(parent); + } + + Arrays.sort(dirs); + + for (File f : dirs) { + adapter.add(f); + } + + currentDir = dir; + pathView.setText(dir.getAbsolutePath()); + } + + @Override + public void onBackPressed() { + File parent = currentDir.getParentFile(); + if (parent != null && + parent.getAbsolutePath().startsWith(rootDir.getAbsolutePath())) { + openDir(parent); + } else { + super.onBackPressed(); + } + } +} diff --git a/app/src/main/java/org/adrianvictor/geleia/activities/MainActivity.java b/app/src/main/java/org/adrianvictor/geleia/activities/MainActivity.java index 0f1df8a7..41a46462 100644 --- a/app/src/main/java/org/adrianvictor/geleia/activities/MainActivity.java +++ b/app/src/main/java/org/adrianvictor/geleia/activities/MainActivity.java @@ -13,11 +13,14 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.drawerlayout.widget.DrawerLayout; +import androidx.lifecycle.Lifecycle; import com.afollestad.materialcab.attached.AttachedCab; import com.afollestad.materialcab.attached.AttachedCabKt; import org.adrianvictor.geleia.activities.base.AbsMusicContentActivity; +import org.adrianvictor.geleia.fragments.OfflineFragment; import org.adrianvictor.geleia.interfaces.CabHolder; +import org.adrianvictor.geleia.util.NavigationUtil; import org.adrianvictor.geleia.util.PreferenceUtil; import org.adrianvictor.geleia.util.ThemeUtil; import org.adrianvictor.geleia.databinding.ActivityMainContentBinding; @@ -42,6 +45,7 @@ public class MainActivity extends AbsMusicContentActivity implements CabHolder { private ActivityMainContentBinding contentBinding; private NavigationDrawerHeaderBinding navigationBinding; private boolean onLogout; + private boolean pendingShowOffline = false; @Nullable private AttachedCab cab; @@ -97,6 +101,15 @@ public class MainActivity extends AbsMusicContentActivity implements CabHolder { }); } + @Override + public void onStateOffline() { + if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) { + NavigationUtil.startUnreachable(this); + } else { + pendingShowOffline = true; + } + } + @Override public void onPause() { super.onPause(); @@ -108,6 +121,16 @@ public class MainActivity extends AbsMusicContentActivity implements CabHolder { } } + @Override + protected void onResume() { + super.onResume(); + + if (pendingShowOffline) { + setCurrentFragment(OfflineFragment.newInstance()); + pendingShowOffline = false; + } + } + private void setCurrentFragment(Fragment fragment) { getSupportFragmentManager().beginTransaction().replace(R.id.fragment_container, fragment, null).commit(); } diff --git a/app/src/main/java/org/adrianvictor/geleia/activities/SearchActivity.java b/app/src/main/java/org/adrianvictor/geleia/activities/SearchActivity.java index 2958e587..b483b51d 100644 --- a/app/src/main/java/org/adrianvictor/geleia/activities/SearchActivity.java +++ b/app/src/main/java/org/adrianvictor/geleia/activities/SearchActivity.java @@ -93,6 +93,9 @@ public class SearchActivity extends AbsMusicContentActivity implements SearchVie public void onStateOnline() { } + @Override + public void onStateOffline() {} + private void setUpToolBar() { binding.toolbar.setBackgroundColor(PreferenceUtil.getInstance(this).getPrimaryColor()); setSupportActionBar(binding.toolbar); diff --git a/app/src/main/java/org/adrianvictor/geleia/activities/SettingsActivity.java b/app/src/main/java/org/adrianvictor/geleia/activities/SettingsActivity.java index b1406474..ab860b08 100644 --- a/app/src/main/java/org/adrianvictor/geleia/activities/SettingsActivity.java +++ b/app/src/main/java/org/adrianvictor/geleia/activities/SettingsActivity.java @@ -51,6 +51,7 @@ public class SettingsActivity extends AbsBaseActivity { public static class SettingsFragment extends PreferenceFragmentCompat implements SharedPreferences.OnSharedPreferenceChangeListener { private ActivityResultLauncher dirPickerLauncher; + private ActivityResultLauncher legacyDirPickerLauncher; @Override public void onCreate(@Nullable Bundle savedInstanceState) { @@ -71,6 +72,25 @@ public class SettingsActivity extends AbsBaseActivity { } } }); + + legacyDirPickerLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == RESULT_OK && result.getData() != null) { + String path = result.getData().getStringExtra( + DirectoryPickerActivity.EXTRA_RESULT_PATH + ); + + if (path != null) { + SharedPreferences.Editor editor = + PreferenceManager.getDefaultSharedPreferences(requireContext()).edit(); + editor.putString(PreferenceUtil.LOCATION_DOWNLOAD, path); + editor.apply(); + invalidateSettings(); + } + } + } + ); } @Override @@ -161,8 +181,13 @@ public class SettingsActivity extends AbsBaseActivity { } private void openDirectoryPicker() { - Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); - dirPickerLauncher.launch(intent); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + dirPickerLauncher.launch(intent); + } else { + Intent intent = new Intent(requireContext(), DirectoryPickerActivity.class); + legacyDirPickerLauncher.launch(intent); + } } @Override diff --git a/app/src/main/java/org/adrianvictor/geleia/activities/SplashActivity.java b/app/src/main/java/org/adrianvictor/geleia/activities/SplashActivity.java index 88a3b8af..d3460d22 100644 --- a/app/src/main/java/org/adrianvictor/geleia/activities/SplashActivity.java +++ b/app/src/main/java/org/adrianvictor/geleia/activities/SplashActivity.java @@ -1,8 +1,13 @@ package org.adrianvictor.geleia.activities; +import android.content.BroadcastReceiver; +import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; +import android.os.Build; import android.os.Bundle; -import android.os.Handler; + +import androidx.annotation.NonNull; import org.adrianvictor.geleia.App; import org.adrianvictor.geleia.R; @@ -15,10 +20,41 @@ import org.adrianvictor.geleia.util.PreferenceUtil; import java.util.List; public class SplashActivity extends AbsBaseActivity { + + private final BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, @NonNull Intent intent) { + if (intent.getAction() == null) { + return; + } + + switch (intent.getAction()) { + case LoginService.STATE_ONLINE: + NavigationUtil.startMain(context); + finish(); + break; + case LoginService.STATE_OFFLINE: + NavigationUtil.startUnreachable(context); + finish(); + break; + } + } + }; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + final IntentFilter filter = new IntentFilter(); + filter.addAction(LoginService.STATE_ONLINE); + filter.addAction(LoginService.STATE_OFFLINE); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED); + } else { + registerReceiver(receiver, filter); + } + setContentView(R.layout.activity_splash); } @@ -35,13 +71,20 @@ public class SplashActivity extends AbsBaseActivity { User user = App.getDatabase().userDao().getUser(PreferenceUtil.getInstance(this).getUser()); List available = App.getDatabase().userDao().getUsers(); - if (user == null && available.size() != 0) { + if (user == null && !available.isEmpty()) { NavigationUtil.startSelect(this); + finish(); } else if (user == null) { NavigationUtil.startLogin(this); + finish(); } else { startService(new Intent(this, LoginService.class)); - new Handler().postDelayed(() -> NavigationUtil.startMain(this), 1000); } } + + @Override + protected void onDestroy() { + unregisterReceiver(receiver); + super.onDestroy(); + } } diff --git a/app/src/main/java/org/adrianvictor/geleia/activities/UnreachableActivity.java b/app/src/main/java/org/adrianvictor/geleia/activities/UnreachableActivity.java new file mode 100644 index 00000000..94afba90 --- /dev/null +++ b/app/src/main/java/org/adrianvictor/geleia/activities/UnreachableActivity.java @@ -0,0 +1,21 @@ +package org.adrianvictor.geleia.activities; + +import android.os.Bundle; +import android.view.View; + +import org.adrianvictor.geleia.R; +import org.adrianvictor.geleia.activities.base.AbsThemeActivity; +import org.adrianvictor.geleia.util.NavigationUtil; + +public class UnreachableActivity extends AbsThemeActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_unreachable); + } + + public void onSelectClick(View view) { + NavigationUtil.startSelect(this); + } +} diff --git a/app/src/main/java/org/adrianvictor/geleia/activities/base/AbsBaseActivity.java b/app/src/main/java/org/adrianvictor/geleia/activities/base/AbsBaseActivity.java index 406f6237..d64b6bc2 100644 --- a/app/src/main/java/org/adrianvictor/geleia/activities/base/AbsBaseActivity.java +++ b/app/src/main/java/org/adrianvictor/geleia/activities/base/AbsBaseActivity.java @@ -59,7 +59,7 @@ public abstract class AbsBaseActivity extends AbsThemeActivity { .setPositiveButton(R.string.disable, (dialog, id) -> requestBatteryOptimization()); new Handler().postDelayed(builder::show, 2000); - } else if (permissions.size() != 0 && ActivityCompat.shouldShowRequestPermissionRationale(this, permissions.get(0))) { + } else if (!permissions.isEmpty() && ActivityCompat.shouldShowRequestPermissionRationale(this, permissions.get(0))) { builder.setMessage(getPermissionMessage()) .setTitle(R.string.permissions_denied) .setPositiveButton(R.string.action_grant, (dialog, id) -> requestPermissions()); @@ -127,8 +127,11 @@ public abstract class AbsBaseActivity extends AbsThemeActivity { private boolean checkBatteryOptimization() { String packageName = getPackageName(); PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); - - return pm.isIgnoringBatteryOptimizations(packageName); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + return pm.isIgnoringBatteryOptimizations(packageName); + } else { + return true; + } } @RequiresApi(api = Build.VERSION_CODES.M) @@ -138,9 +141,11 @@ public abstract class AbsBaseActivity extends AbsThemeActivity { @RequiresApi(api = Build.VERSION_CODES.M) private boolean checkPermissions() { - for (String permission : permissions) { - if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) { - return false; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + for (String permission : permissions) { + if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) { + return false; + } } } diff --git a/app/src/main/java/org/adrianvictor/geleia/activities/base/AbsMusicContentActivity.java b/app/src/main/java/org/adrianvictor/geleia/activities/base/AbsMusicContentActivity.java index 904b928f..90843227 100644 --- a/app/src/main/java/org/adrianvictor/geleia/activities/base/AbsMusicContentActivity.java +++ b/app/src/main/java/org/adrianvictor/geleia/activities/base/AbsMusicContentActivity.java @@ -1,14 +1,19 @@ package org.adrianvictor.geleia.activities.base; +import static org.adrianvictor.geleia.adapter.CustomFragmentStatePagerAdapter.TAG; + import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.os.Build; import android.os.Bundle; +import android.util.Log; import androidx.annotation.NonNull; import org.adrianvictor.geleia.App; +import org.adrianvictor.geleia.fragments.OfflineFragment; import org.adrianvictor.geleia.interfaces.StateListener; import org.adrianvictor.geleia.service.LoginService; import org.adrianvictor.geleia.util.NavigationUtil; @@ -24,7 +29,7 @@ public abstract class AbsMusicContentActivity extends AbsMusicPanelActivity impl onStateOnline(); break; case LoginService.STATE_OFFLINE: - NavigationUtil.startLogin(context); + onStateOffline(); break; } } @@ -39,7 +44,11 @@ public abstract class AbsMusicContentActivity extends AbsMusicPanelActivity impl filter.addAction(LoginService.STATE_ONLINE); filter.addAction(LoginService.STATE_OFFLINE); - registerReceiver(receiver, filter); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED); + } else { + registerReceiver(receiver, filter); + } if (App.getApiClient() == null) { startService(new Intent(this, LoginService.class)); @@ -51,24 +60,14 @@ public abstract class AbsMusicContentActivity extends AbsMusicPanelActivity impl @Override protected void onResume() { super.onResume(); - - if (App.getApiClient() == null) { - startService(new Intent(this, LoginService.class)); - } } @Override protected void onDestroy() { unregisterReceiver(receiver); - super.onDestroy(); } @Override - public void onStatePolling() { - } - - @Override - public void onStateOffline() { - } + public void onStatePolling() {} } diff --git a/app/src/main/java/org/adrianvictor/geleia/activities/details/AlbumDetailActivity.java b/app/src/main/java/org/adrianvictor/geleia/activities/details/AlbumDetailActivity.java index 9c7ca01b..ad920baa 100644 --- a/app/src/main/java/org/adrianvictor/geleia/activities/details/AlbumDetailActivity.java +++ b/app/src/main/java/org/adrianvictor/geleia/activities/details/AlbumDetailActivity.java @@ -74,6 +74,11 @@ public class AlbumDetailActivity extends AbsMusicContentActivity implements Pale }); } + @Override + public void onStateOffline() { + + } + @Override public void onOffsetChanged (AppBarLayout appBarLayout, int verticalOffset) { float headerAlpha = Math.max(0, Math.min(1, 1 + (2 * (float) verticalOffset / headerViewHeight))); @@ -231,6 +236,6 @@ public class AlbumDetailActivity extends AbsMusicContentActivity implements Pale binding.durationText.setText(MusicUtil.getReadableDurationString(MusicUtil.getTotalDuration(this, album.songs))); binding.albumYearText.setText(MusicUtil.getYearString(album.year)); - if (album.songs.size() != 0) adapter.swapDataSet(album.songs); + if (!album.songs.isEmpty()) adapter.swapDataSet(album.songs); } } diff --git a/app/src/main/java/org/adrianvictor/geleia/activities/details/ArtistDetailActivity.java b/app/src/main/java/org/adrianvictor/geleia/activities/details/ArtistDetailActivity.java index d7cde428..2f98a935 100644 --- a/app/src/main/java/org/adrianvictor/geleia/activities/details/ArtistDetailActivity.java +++ b/app/src/main/java/org/adrianvictor/geleia/activities/details/ArtistDetailActivity.java @@ -87,6 +87,11 @@ public class ArtistDetailActivity extends AbsMusicContentActivity implements Pal }); } + @Override + public void onStateOffline() { + + } + @Override public void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) { float headerAlpha = Math.max(0, Math.min(1, 1 + (2 * (float) verticalOffset / headerViewHeight))); @@ -258,7 +263,7 @@ public class ArtistDetailActivity extends AbsMusicContentActivity implements Pal binding.albumCountText.setText(MusicUtil.getAlbumCountString(this, artist.albums.size())); binding.durationText.setText(MusicUtil.getReadableDurationString(MusicUtil.getTotalDuration(this, artist.songs))); - if (artist.songs.size() != 0) songAdapter.swapDataSet(artist.songs); - if (artist.albums.size() != 0) albumAdapter.swapDataSet(artist.albums); + if (!artist.songs.isEmpty()) songAdapter.swapDataSet(artist.songs); + if (!artist.albums.isEmpty()) albumAdapter.swapDataSet(artist.albums); } } diff --git a/app/src/main/java/org/adrianvictor/geleia/activities/details/GenreDetailActivity.java b/app/src/main/java/org/adrianvictor/geleia/activities/details/GenreDetailActivity.java index 9449daa5..aacc7db8 100644 --- a/app/src/main/java/org/adrianvictor/geleia/activities/details/GenreDetailActivity.java +++ b/app/src/main/java/org/adrianvictor/geleia/activities/details/GenreDetailActivity.java @@ -59,6 +59,11 @@ public class GenreDetailActivity extends AbsMusicContentActivity implements CabH }); } + @Override + public void onStateOffline() { + + } + @Override protected View createContentView() { binding = ActivityGenreDetailBinding.inflate(getLayoutInflater()); diff --git a/app/src/main/java/org/adrianvictor/geleia/activities/details/PlaylistDetailActivity.java b/app/src/main/java/org/adrianvictor/geleia/activities/details/PlaylistDetailActivity.java index f111dc14..bb3d1d57 100644 --- a/app/src/main/java/org/adrianvictor/geleia/activities/details/PlaylistDetailActivity.java +++ b/app/src/main/java/org/adrianvictor/geleia/activities/details/PlaylistDetailActivity.java @@ -71,6 +71,11 @@ public class PlaylistDetailActivity extends AbsMusicContentActivity implements C }); } + @Override + public void onStateOffline() { + + } + @Override protected View createContentView() { binding = ActivityPlaylistDetailBinding.inflate(getLayoutInflater()); diff --git a/app/src/main/java/org/adrianvictor/geleia/adapter/CustomFragmentStatePagerAdapter.java b/app/src/main/java/org/adrianvictor/geleia/adapter/CustomFragmentStatePagerAdapter.java index d19a6ffe..f6c1c4f5 100644 --- a/app/src/main/java/org/adrianvictor/geleia/adapter/CustomFragmentStatePagerAdapter.java +++ b/app/src/main/java/org/adrianvictor/geleia/adapter/CustomFragmentStatePagerAdapter.java @@ -180,7 +180,7 @@ public abstract class CustomFragmentStatePagerAdapter extends PagerAdapter { @Override public Parcelable saveState() { Bundle state = null; - if (mSavedState.size() > 0) { + if (!mSavedState.isEmpty()) { state = new Bundle(); Fragment.SavedState[] fss = new Fragment.SavedState[mSavedState.size()]; mSavedState.toArray(fss); diff --git a/app/src/main/java/org/adrianvictor/geleia/adapter/DownloadsAdapter.java b/app/src/main/java/org/adrianvictor/geleia/adapter/DownloadsAdapter.java new file mode 100644 index 00000000..25cf4c79 --- /dev/null +++ b/app/src/main/java/org/adrianvictor/geleia/adapter/DownloadsAdapter.java @@ -0,0 +1,69 @@ +package org.adrianvictor.geleia.adapter; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.adrianvictor.geleia.R; +import org.adrianvictor.geleia.model.Song; + +import java.util.ArrayList; +import java.util.List; + +public class DownloadsAdapter extends RecyclerView.Adapter { + private final List mSongs; + private final int mLayoutId; + + public DownloadsAdapter(int layoutId) { + mLayoutId = layoutId; + this.mSongs = new ArrayList<>(); + } + + public void swapDataSet(List newSongs) { + mSongs.clear(); + if (newSongs != null) { + mSongs.addAll(newSongs); + } + notifyDataSetChanged(); + } + + @NonNull + @Override + public DownloadsAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(mLayoutId, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull DownloadsAdapter.ViewHolder holder, int position) { + final Song song = mSongs.get(position); + + holder.title.setText(song.title); + holder.artist.setText(song.artistName); + + // TODO: Load album cover into holder.cover using Glide + } + + @Override + public int getItemCount() { + return mSongs.size(); + } + + public static class ViewHolder extends RecyclerView.ViewHolder { + public final TextView title; + public final TextView artist; + public final ImageView cover; + + public ViewHolder(View itemView) { + super(itemView); + title = itemView.findViewById(R.id.title); + artist = itemView.findViewById(R.id.text); + cover = itemView.findViewById(R.id.image); + } + } +} diff --git a/app/src/main/java/org/adrianvictor/geleia/adapter/MusicLibraryPagerAdapter.java b/app/src/main/java/org/adrianvictor/geleia/adapter/MusicLibraryPagerAdapter.java index 2979ee5c..23c1162d 100644 --- a/app/src/main/java/org/adrianvictor/geleia/adapter/MusicLibraryPagerAdapter.java +++ b/app/src/main/java/org/adrianvictor/geleia/adapter/MusicLibraryPagerAdapter.java @@ -10,6 +10,9 @@ import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentPagerAdapter; +import org.adrianvictor.geleia.activities.UnreachableActivity; +import org.adrianvictor.geleia.fragments.OfflineFragment; +import org.adrianvictor.geleia.fragments.library.DownloadsFragment; import org.adrianvictor.geleia.fragments.library.FavoritesFragment; import org.adrianvictor.geleia.model.Category; import org.adrianvictor.geleia.fragments.library.AlbumsFragment; @@ -157,7 +160,8 @@ public class MusicLibraryPagerAdapter extends FragmentPagerAdapter { ARTISTS(ArtistsFragment.class), GENRES(GenresFragment.class), PLAYLISTS(PlaylistsFragment.class), - FAVORITES(FavoritesFragment.class); + FAVORITES(FavoritesFragment.class), + DOWNLOADS(DownloadsFragment.class); private final Class mFragmentClass; diff --git a/app/src/main/java/org/adrianvictor/geleia/database/CacheDao.java b/app/src/main/java/org/adrianvictor/geleia/database/CacheDao.java index 8df0ded5..54ff3733 100644 --- a/app/src/main/java/org/adrianvictor/geleia/database/CacheDao.java +++ b/app/src/main/java/org/adrianvictor/geleia/database/CacheDao.java @@ -14,6 +14,9 @@ public interface CacheDao { @Insert(onConflict = OnConflictStrategy.REPLACE) void insertCache(Cache cache); + @Query("SELECT * FROM cache") + List getAll(); + @Query("SELECT * FROM songs LEFT JOIN cache USING(id) WHERE songs.id IN (:ids)") List getSongs(List ids); diff --git a/app/src/main/java/org/adrianvictor/geleia/dialogs/SongShareDialog.java b/app/src/main/java/org/adrianvictor/geleia/dialogs/SongShareDialog.java index 101c9377..d2becf6a 100644 --- a/app/src/main/java/org/adrianvictor/geleia/dialogs/SongShareDialog.java +++ b/app/src/main/java/org/adrianvictor/geleia/dialogs/SongShareDialog.java @@ -31,7 +31,7 @@ public class SongShareDialog extends DialogFragment { final String currentlyListening = getString(R.string.currently_listening_to_x_by_x, song.title, song.artistName); return new MaterialDialog.Builder(requireActivity()) .title(R.string.what_do_you_want_to_share) - .items(getString(R.string.the_audio_file), "\u201C" + currentlyListening + "\u201D") + .items(getString(R.string.the_audio_file), "“" + currentlyListening + "”") .itemsCallback((materialDialog, view, i, charSequence) -> { switch (i) { case 0: diff --git a/app/src/main/java/org/adrianvictor/geleia/fragments/OfflineFragment.java b/app/src/main/java/org/adrianvictor/geleia/fragments/OfflineFragment.java new file mode 100644 index 00000000..ed1d40a0 --- /dev/null +++ b/app/src/main/java/org/adrianvictor/geleia/fragments/OfflineFragment.java @@ -0,0 +1,25 @@ +package org.adrianvictor.geleia.fragments; + +import android.app.Activity; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import org.adrianvictor.geleia.R; + +public class OfflineFragment extends Fragment { + public static OfflineFragment newInstance() { + return new OfflineFragment(); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_offline, container, false); + } +} diff --git a/app/src/main/java/org/adrianvictor/geleia/fragments/library/DownloadsFragment.java b/app/src/main/java/org/adrianvictor/geleia/fragments/library/DownloadsFragment.java new file mode 100644 index 00000000..f6f259f5 --- /dev/null +++ b/app/src/main/java/org/adrianvictor/geleia/fragments/library/DownloadsFragment.java @@ -0,0 +1,125 @@ +package org.adrianvictor.geleia.fragments.library; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.GridLayoutManager; + +import org.adrianvictor.geleia.App; +import org.adrianvictor.geleia.adapter.DownloadsAdapter; + +import org.adrianvictor.geleia.database.Cache; +import org.adrianvictor.geleia.model.Song; +import org.adrianvictor.geleia.model.SortMethod; +import org.adrianvictor.geleia.model.SortOrder; + +import java.util.ArrayList; +import java.util.List; + +public class DownloadsFragment extends AbsLibraryPagerRecyclerViewCustomGridSizeFragment { + @NonNull + @Override + protected DownloadsAdapter createAdapter() { + return new DownloadsAdapter(getItemLayoutRes()); + } + + @NonNull + @Override + protected GridLayoutManager createLayoutManager() { + return new GridLayoutManager(getActivity(), getGridSize()); + } + + @NonNull + @Override + protected Void createQuery() { + return null; + } + + @Override + protected void loadItems(int index) { + new Thread(() -> { + List cachedEntries = App.getDatabase().cacheDao().getAll(); + + List songIds = new ArrayList<>(); + + for (Cache entry : cachedEntries) { + songIds.add(entry.id); + } + + final List downloadedSongs = App.getDatabase().cacheDao().getSongs(songIds); + + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + getAdapter().swapDataSet(downloadedSongs); + }); + } + }).start(); + } + @Override + protected int loadGridSize() { + return 1; + } + + @Override + protected void saveGridSize(int gridColumns) { + + } + + @Override + protected int loadGridSizeLand() { + return 1; + } + + @Override + protected void saveGridSizeLand(int gridColumns) { + + } + + @Override + protected void saveUsePalette(boolean usePalette) { + + } + + @Override + protected boolean loadUsePalette() { + return false; + } + + @Override + protected void setUsePalette(boolean usePalette) { + + } + + @Override + protected void setGridSize(int gridSize) { + + } + + @Override + protected SortMethod loadSortMethod() { + return SortMethod.ADDED; + } + + @Override + protected void saveSortMethod(SortMethod sortMethod) { + + } + + @Override + protected void setSortMethod(SortMethod sortMethod) { + + } + + @Override + protected SortOrder loadSortOrder() { + return SortOrder.ASCENDING; + } + + @Override + protected void saveSortOrder(SortOrder sortOrder) { + + } + + @Override + protected void setSortOrder(SortOrder sortOrder) { + + } +} diff --git a/app/src/main/java/org/adrianvictor/geleia/helper/MusicPlayerRemote.java b/app/src/main/java/org/adrianvictor/geleia/helper/MusicPlayerRemote.java index 4f658348..f409842c 100644 --- a/app/src/main/java/org/adrianvictor/geleia/helper/MusicPlayerRemote.java +++ b/app/src/main/java/org/adrianvictor/geleia/helper/MusicPlayerRemote.java @@ -273,7 +273,7 @@ public class MusicPlayerRemote { public static boolean playNext(Song song) { if (musicService != null && musicService.queueManager != null) { - if (getPlayingQueue().size() > 0) { + if (!getPlayingQueue().isEmpty()) { musicService.queueManager.addSong(getPosition() + 1, song); } else { List queue = new ArrayList<>(); @@ -290,7 +290,7 @@ public class MusicPlayerRemote { public static boolean playNext(@NonNull List songs) { if (musicService != null && musicService.queueManager != null) { - if (getPlayingQueue().size() > 0) { + if (!getPlayingQueue().isEmpty()) { musicService.queueManager.addSongs(getPosition() + 1, songs); } else { openQueue(songs, 0, false); @@ -306,7 +306,7 @@ public class MusicPlayerRemote { public static boolean enqueue(Song song) { if (musicService != null && musicService.queueManager != null) { - if (getPlayingQueue().size() > 0) { + if (!getPlayingQueue().isEmpty()) { musicService.queueManager.addSong(song); } else { List queue = new ArrayList<>(); @@ -323,7 +323,7 @@ public class MusicPlayerRemote { public static boolean enqueue(@NonNull List songs) { if (musicService != null && musicService.queueManager != null) { - if (getPlayingQueue().size() > 0) { + if (!getPlayingQueue().isEmpty()) { musicService.queueManager.addSongs(songs); } else { openQueue(songs, 0, false); diff --git a/app/src/main/java/org/adrianvictor/geleia/model/Album.java b/app/src/main/java/org/adrianvictor/geleia/model/Album.java index 36ff5554..81e4e481 100644 --- a/app/src/main/java/org/adrianvictor/geleia/model/Album.java +++ b/app/src/main/java/org/adrianvictor/geleia/model/Album.java @@ -29,10 +29,10 @@ public class Album implements Parcelable { this.title = itemDto.getName(); this.year = itemDto.getProductionYear() != null ? itemDto.getProductionYear() : 0; - if (itemDto.getAlbumArtists().size() != 0) { + if (!itemDto.getAlbumArtists().isEmpty()) { this.artistId = itemDto.getAlbumArtists().get(0).getId(); this.artistName = itemDto.getAlbumArtists().get(0).getName(); - } else if (itemDto.getArtistItems().size() != 0) { + } else if (!itemDto.getArtistItems().isEmpty()) { this.artistId = itemDto.getArtistItems().get(0).getId(); this.artistName = itemDto.getArtistItems().get(0).getName(); } diff --git a/app/src/main/java/org/adrianvictor/geleia/model/Category.java b/app/src/main/java/org/adrianvictor/geleia/model/Category.java index 91e54717..3c897e81 100644 --- a/app/src/main/java/org/adrianvictor/geleia/model/Category.java +++ b/app/src/main/java/org/adrianvictor/geleia/model/Category.java @@ -10,7 +10,8 @@ public enum Category { ARTISTS(R.string.artists), GENRES(R.string.genres), PLAYLISTS(R.string.playlists), - FAVORITES(R.string.favorites); + FAVORITES(R.string.favorites), + DOWNLOADS(R.string.downloads); @StringRes public final int title; diff --git a/app/src/main/java/org/adrianvictor/geleia/model/Song.java b/app/src/main/java/org/adrianvictor/geleia/model/Song.java index 277a5942..b9218cb3 100644 --- a/app/src/main/java/org/adrianvictor/geleia/model/Song.java +++ b/app/src/main/java/org/adrianvictor/geleia/model/Song.java @@ -67,10 +67,10 @@ public class Song implements Parcelable { this.albumId = itemDto.getAlbumId(); this.albumName = itemDto.getAlbum(); - if (itemDto.getArtistItems().size() != 0) { + if (!itemDto.getArtistItems().isEmpty()) { this.artistId = itemDto.getArtistItems().get(0).getId(); this.artistName = itemDto.getArtistItems().get(0).getName(); - } else if (itemDto.getAlbumArtists().size() != 0) { + } else if (!itemDto.getAlbumArtists().isEmpty()) { this.artistId = itemDto.getAlbumArtists().get(0).getId(); this.artistName = itemDto.getAlbumArtists().get(0).getName(); } @@ -93,7 +93,7 @@ public class Song implements Parcelable { this.supportsTranscoding = source.getSupportsTranscoding(); - if (source.getMediaStreams() != null && source.getMediaStreams().size() != 0) { + if (source.getMediaStreams() != null && !source.getMediaStreams().isEmpty()) { MediaStream stream = source.getMediaStreams().get(0); this.codec = stream.getCodec(); diff --git a/app/src/main/java/org/adrianvictor/geleia/service/DownloadService.java b/app/src/main/java/org/adrianvictor/geleia/service/DownloadService.java index b3a61b5f..7cb72766 100644 --- a/app/src/main/java/org/adrianvictor/geleia/service/DownloadService.java +++ b/app/src/main/java/org/adrianvictor/geleia/service/DownloadService.java @@ -4,6 +4,8 @@ import android.app.Service; import android.content.Intent; import android.net.Uri; import android.os.IBinder; +import android.util.Log; + import androidx.documentfile.provider.DocumentFile; import org.adrianvictor.geleia.App; @@ -11,6 +13,7 @@ import org.adrianvictor.geleia.BuildConfig; import org.adrianvictor.geleia.database.Cache; import org.adrianvictor.geleia.model.Song; import org.adrianvictor.geleia.service.notifications.DownloadNotification; +import org.adrianvictor.geleia.service.notifications.ErrorNotification; import org.adrianvictor.geleia.util.MusicUtil; import org.adrianvictor.geleia.util.PreferenceUtil; @@ -23,8 +26,8 @@ import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -@SuppressWarnings("ResultOfMethodCallIgnored") public class DownloadService extends Service { + public static final String TAG = DownloadService.class.getSimpleName(); public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID; public static final String ACTION_START = PACKAGE_NAME + ".action.start"; public static final String ACTION_CANCEL = PACKAGE_NAME + ".action.cancel"; @@ -33,6 +36,8 @@ public class DownloadService extends Service { private ExecutorService executor; private DownloadNotification notification; + private static final Object lock = new Object(); + @Override public void onCreate() { super.onCreate(); @@ -55,6 +60,7 @@ public class DownloadService extends Service { break; case DownloadService.ACTION_START: List songs = intent.getParcelableArrayListExtra(EXTRA_SONGS); + assert songs != null; for (Song song : songs) { download(song); notification.start(song); @@ -78,19 +84,24 @@ public class DownloadService extends Service { String location = PreferenceUtil.getInstance(App.getInstance()).getLocationDownload(); DocumentFile root; - if (location.equals(getApplicationContext().getCacheDir().toString())) { - root = DocumentFile.fromFile(new File(location)); - } else { + if (location.startsWith("content://")) { root = DocumentFile.fromTreeUri(this, Uri.parse(location)); + } else { + root = DocumentFile.fromFile(new File(location)); } - DocumentFile artist = root.findFile(MusicUtil.ascii(song.artistName)); - if (artist == null) { - artist = root.createDirectory(MusicUtil.ascii(song.artistName)); - } - DocumentFile album = artist.findFile(MusicUtil.ascii(song.albumName)); - if (album == null) { - album = artist.createDirectory(MusicUtil.ascii(song.albumName)); + DocumentFile artist; + DocumentFile album; + + synchronized (lock) { + artist = root.findFile(MusicUtil.ascii(song.artistName)); + if (artist == null) { + artist = root.createDirectory(MusicUtil.ascii(song.artistName)); + } + album = artist.findFile(MusicUtil.ascii(song.albumName)); + if (album == null) { + album = artist.createDirectory(MusicUtil.ascii(song.albumName)); + } } String fileName = song.discNumber + "." + song.trackNumber + " - " + MusicUtil.ascii(song.title) + "." + song.container; @@ -116,7 +127,8 @@ public class DownloadService extends Service { App.getDatabase().cacheDao().insertCache(new Cache(song)); notification.stop(song); } catch (Exception e) { - e.printStackTrace(); + Log.e(TAG, "Failed to download song: " + song.title, e); + ErrorNotification.show(this, e.getMessage()); } }); } diff --git a/app/src/main/java/org/adrianvictor/geleia/service/LoginService.java b/app/src/main/java/org/adrianvictor/geleia/service/LoginService.java index 82f7a2a7..55db8664 100644 --- a/app/src/main/java/org/adrianvictor/geleia/service/LoginService.java +++ b/app/src/main/java/org/adrianvictor/geleia/service/LoginService.java @@ -46,9 +46,19 @@ public class LoginService extends Service { if (user == null) { Toast.makeText(this, context.getResources().getString(R.string.error_unexpected), Toast.LENGTH_SHORT).show(); + sendBroadcast(new Intent(STATE_OFFLINE)); return; } + if (App.getApiClient() == null) { + try { + App.createApiClient(context); + } catch (Exception e) { + sendBroadcast(new Intent(STATE_OFFLINE)); + return; + } + } + App.getApiClient().ChangeServerLocation(user.server); App.getApiClient().SetAuthenticationInfo(user.token, user.id); App.getApiClient().GetSystemInfoAsync(new Response() { diff --git a/app/src/main/java/org/adrianvictor/geleia/service/notifications/DownloadNotification.java b/app/src/main/java/org/adrianvictor/geleia/service/notifications/DownloadNotification.java index fe6c0efa..014eb570 100644 --- a/app/src/main/java/org/adrianvictor/geleia/service/notifications/DownloadNotification.java +++ b/app/src/main/java/org/adrianvictor/geleia/service/notifications/DownloadNotification.java @@ -1,114 +1,134 @@ package org.adrianvictor.geleia.service.notifications; +import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.os.Build; +import android.widget.RemoteViews; -import androidx.annotation.RequiresApi; import androidx.core.app.NotificationCompat; import org.adrianvictor.geleia.R; -import org.adrianvictor.geleia.activities.MainActivity; import org.adrianvictor.geleia.model.Song; import org.adrianvictor.geleia.service.DownloadService; -import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedList; import java.util.List; -import java.util.stream.Collectors; - -import static android.content.Context.NOTIFICATION_SERVICE; public class DownloadNotification { - private static final String CHANNEL_ID = DownloadNotification.class.getSimpleName(); - private static final int NOTIFICATION_ID = 2; - + private static final String CHANNEL_ID = "download_channel"; + private final int ID = 2; private final Context context; private final NotificationManager notificationManager; + private final List queue = Collections.synchronizedList(new LinkedList<>()); - private final List songs; - - private int current; - private int maximum; + private long totalSize; + private long downloadedSize; + private int lastPercentage = -1; public DownloadNotification(Context context) { - this.notificationManager = (NotificationManager) context.getSystemService(NOTIFICATION_SERVICE); this.context = context; - - this.songs = new ArrayList<>(); + this.notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + createNotificationChannel(); } - public synchronized void start(Song song) { - this.songs.add(song); - + private void createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannel(); + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + context.getString(R.string.download_channel_name), + NotificationManager.IMPORTANCE_LOW + ); + notificationManager.createNotificationChannel(channel); } } - public synchronized void update(int current, int maximum) { - this.current += current; - this.maximum += maximum; - - Intent action = new Intent(context, MainActivity.class).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); - PendingIntent clickIntent = PendingIntent.getActivity(context, 0, action, PendingIntent.FLAG_IMMUTABLE); - - Intent cancel = new Intent(context, DownloadService.class).setAction(DownloadService.ACTION_CANCEL); - PendingIntent pendingCancel = PendingIntent.getService(context, 0, cancel, PendingIntent.FLAG_IMMUTABLE); - - NotificationCompat.InboxStyle style = new NotificationCompat.InboxStyle(); - for (Song item : songs.stream().limit(5).collect(Collectors.toList())) { - style.addLine(item.title); + public void start(Song song) { + queue.add(song); + if (queue.size() == 1) { // This is the first song of a new batch + totalSize = 0; + downloadedSize = 0; + lastPercentage = -1; + notificationManager.notify(ID, getNotification()); + } else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { + // For KitKat, update for every new song to show correct count. + notificationManager.notify(ID, getNotification()); } + } + + public void update(long downloaded, long total) { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { + return; // No progress updates for KitKat + } + + synchronized (this) { + totalSize += total; + downloadedSize += downloaded; + + int percentage = 0; + if (totalSize > 0) { + percentage = (int) ((downloadedSize * 100) / totalSize); + } + + if (percentage > lastPercentage) { + lastPercentage = percentage; + notificationManager.notify(ID, getNotification()); + } + } + } + + public void stop(Song song) { + queue.remove(song); + if (queue.isEmpty()) { + notificationManager.cancel(ID); + } else { + // Update notification to show new queue size. + // On KitKat, this is the only update after a download finishes. + notificationManager.notify(ID, getNotification()); + } + } + + public void cancelAll() { + queue.clear(); + notificationManager.cancel(ID); + } + + private Notification getNotification() { + Intent intent = new Intent(context, DownloadService.class); + intent.setAction(DownloadService.ACTION_CANCEL); + + int flags = 0; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + PendingIntent pendingIntent = PendingIntent.getService(context, 0, intent, flags); NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setContentIntent(clickIntent) - .setContentTitle(String.format(context.getString(R.string.downloading_x_songs), songs.size())) - .setProgress(this.maximum, this.current, false) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .addAction(R.drawable.ic_close_white_24dp, context.getString(R.string.action_cancel), pendingCancel) - .setStyle(style) - .setShowWhen(false); + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.downloading_songs)) + .setOngoing(true) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, context.getString(android.R.string.cancel), pendingIntent); - notificationManager.notify(NOTIFICATION_ID, builder.build()); - } + String contentText = context.getResources().getQuantityString(R.plurals.downloading_s_songs, queue.size(), queue.size()); - public synchronized void stop(Song song) { - if (song != null) { - songs.remove(song); - } else { - songs.clear(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.notification_download); + remoteViews.setTextViewText(R.id.notification_download_title, contentText); + int progress = lastPercentage > 0 ? lastPercentage : 0; + remoteViews.setProgressBar(R.id.notification_download_progress, 100, progress, totalSize == 0 && downloadedSize == 0); + builder.setCustomContentView(remoteViews); + } else if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) { + int progress = lastPercentage > 0 ? lastPercentage : 0; + builder.setProgress(100, progress, totalSize == 0 && downloadedSize == 0); + builder.setContentText(contentText); + } else { // KitKat + builder.setContentText(contentText); } - if (songs.size() != 0) { - return; - } - - current = 0; - maximum = 0; - - notificationManager.cancel(NOTIFICATION_ID); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - notificationManager.deleteNotificationChannel(CHANNEL_ID); - } - } - - @RequiresApi(Build.VERSION_CODES.O) - private void createNotificationChannel() { - NotificationChannel notificationChannel = notificationManager.getNotificationChannel(CHANNEL_ID); - - if (notificationChannel == null) { - notificationChannel = new NotificationChannel(CHANNEL_ID, context.getString(R.string.action_download), NotificationManager.IMPORTANCE_LOW); - - notificationChannel.setDescription(context.getString(R.string.playing_notification_description)); - notificationChannel.enableLights(false); - notificationChannel.enableVibration(false); - - notificationManager.createNotificationChannel(notificationChannel); - } + return builder.build(); } } diff --git a/app/src/main/java/org/adrianvictor/geleia/service/notifications/ErrorNotification.java b/app/src/main/java/org/adrianvictor/geleia/service/notifications/ErrorNotification.java new file mode 100644 index 00000000..793a9db7 --- /dev/null +++ b/app/src/main/java/org/adrianvictor/geleia/service/notifications/ErrorNotification.java @@ -0,0 +1,49 @@ +package org.adrianvictor.geleia.service.notifications; + +import static android.content.Context.NOTIFICATION_SERVICE; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; + +import androidx.core.app.NotificationCompat; + +import org.adrianvictor.geleia.R; + +public class ErrorNotification { + private static final String CHANNEL_ID = ErrorNotification.class.getSimpleName(); + private static final int NOTIFICATION_ID = 3; + + private ErrorNotification() {} + + public static void show(Context context, String error) { + NotificationManager notificationManager = (NotificationManager) context.getSystemService(NOTIFICATION_SERVICE); + createNotificationChannel(notificationManager); + + NotificationCompat.BigTextStyle style = new NotificationCompat.BigTextStyle(); + style.setBigContentTitle("Error:").bigText(error); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_bug_report_white_24dp) + .setContentTitle(context.getString(R.string.error_notification_title)) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setStyle(style) + .setContentText("Expand the notification for details."); + } + + public static void createNotificationChannel(NotificationManager notificationManager) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = notificationManager.getNotificationChannel(CHANNEL_ID); + + if (channel == null) { + channel = new NotificationChannel(CHANNEL_ID, "Errors", NotificationManager.IMPORTANCE_LOW); + channel.setDescription("Displays all sorts of errors."); + channel.enableLights(false); + channel.enableVibration(true); + + notificationManager.createNotificationChannel(channel); + } + } + } +} diff --git a/app/src/main/java/org/adrianvictor/geleia/service/playback/LocalPlayer.java b/app/src/main/java/org/adrianvictor/geleia/service/playback/LocalPlayer.java index 0a63184d..2832d553 100644 --- a/app/src/main/java/org/adrianvictor/geleia/service/playback/LocalPlayer.java +++ b/app/src/main/java/org/adrianvictor/geleia/service/playback/LocalPlayer.java @@ -2,6 +2,7 @@ package org.adrianvictor.geleia.service.playback; import android.content.Context; import android.net.Uri; +import android.os.Build; import android.util.Log; import android.widget.Toast; @@ -141,11 +142,17 @@ public class LocalPlayer implements Playback { private List createMediaItems(List queue) { return queue.stream().map(song -> { - File audio = new File(MusicUtil.getFileUri(song)); - Uri uri = Uri.fromFile(audio); + String fileUri = MusicUtil.getFileUri(song); + Uri uri; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && fileUri.startsWith("content://")) { + uri = Uri.parse(fileUri); + } else { + File audio = new File(fileUri); + uri = Uri.fromFile(audio); - if (!audio.exists()) { - uri = Uri.parse(MusicUtil.getTranscodeUri(song)); + if (!audio.exists()) { + uri = Uri.parse(MusicUtil.getTranscodeUri(song)); + } } List containers = PreferenceUtil.getInstance(context).getDirectPlayCodecs().stream() diff --git a/app/src/main/java/org/adrianvictor/geleia/util/ImageUtil.java b/app/src/main/java/org/adrianvictor/geleia/util/ImageUtil.java index 1388f345..de326d91 100644 --- a/app/src/main/java/org/adrianvictor/geleia/util/ImageUtil.java +++ b/app/src/main/java/org/adrianvictor/geleia/util/ImageUtil.java @@ -14,6 +14,7 @@ import androidx.annotation.ColorInt; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; import androidx.core.graphics.drawable.DrawableCompat; import androidx.core.content.res.ResourcesCompat; @@ -91,18 +92,22 @@ public class ImageUtil { } public static Drawable getTintedVectorDrawable(@NonNull Context context, @DrawableRes int resId, @ColorInt int color) { - Drawable drawable = getVectorDrawable(context.getResources(), resId, context.getTheme()); + final Drawable drawable = AppCompatResources.getDrawable(context, resId); - DrawableCompat.setTintMode(drawable, PorterDuff.Mode.SRC_IN); - DrawableCompat.setTint(drawable, color); + if (drawable != null) { + Drawable wrappedDrawable = DrawableCompat.wrap(drawable).mutate(); - return drawable; + DrawableCompat.setTintMode(wrappedDrawable, PorterDuff.Mode.SRC_IN); + DrawableCompat.setTint(wrappedDrawable, color); + return wrappedDrawable; + } + return null; } + public static Drawable getVectorDrawable(@NonNull Context context, @DrawableRes int resId) { - return getVectorDrawable(context.getResources(), resId, context.getTheme()); + return AppCompatResources.getDrawable(context, resId); } - public static Drawable resolveDrawable(@NonNull Context context, @AttrRes int drawableAttr) { TypedArray a = context.obtainStyledAttributes(new int[]{drawableAttr}); Drawable drawable = a.getDrawable(0); diff --git a/app/src/main/java/org/adrianvictor/geleia/util/MusicUtil.java b/app/src/main/java/org/adrianvictor/geleia/util/MusicUtil.java index 8831fe38..8d62cbbf 100644 --- a/app/src/main/java/org/adrianvictor/geleia/util/MusicUtil.java +++ b/app/src/main/java/org/adrianvictor/geleia/util/MusicUtil.java @@ -3,6 +3,7 @@ package org.adrianvictor.geleia.util; import android.content.Context; import android.content.Intent; import android.net.Uri; +import android.os.Build; import android.text.TextUtils; import android.util.Log; import android.widget.Toast; @@ -18,6 +19,7 @@ import org.adrianvictor.geleia.model.Codec; import org.adrianvictor.geleia.model.Genre; import org.adrianvictor.geleia.model.Song; +import org.adrianvictor.geleia.service.notifications.ErrorNotification; import org.jellyfin.apiclient.interaction.ApiClient; import org.jellyfin.apiclient.interaction.Response; import org.jellyfin.apiclient.model.dto.UserItemDataDto; @@ -49,7 +51,7 @@ public class MusicUtil { List codecs = preferenceUtil.getDirectPlayCodecs(); Stream values = codecs.stream().map(codec -> codec.value); - if (codecs.size() != 0) { + if (!codecs.isEmpty()) { builder.append("&Container=").append(values.collect(Collectors.joining(","))); } @@ -81,7 +83,8 @@ public class MusicUtil { public static String getFileUri(Song song) { String location = PreferenceUtil.getInstance(App.getInstance()).getLocationDownload(); File root = new File(location, "music"); - if (!location.equals(App.getInstance().getCacheDir().toString())) { + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !location.equals(App.getInstance().getCacheDir().toString())) { Uri uri = Uri.parse(location); return new File(uri.getPath()).getAbsolutePath(); } @@ -102,7 +105,7 @@ public class MusicUtil { try { return new Intent(); } catch (IllegalArgumentException e) { - e.printStackTrace(); + ErrorNotification.show(context, e.getMessage()); Toast.makeText(context, R.string.error_share_file, Toast.LENGTH_SHORT).show(); return new Intent(); } @@ -110,7 +113,7 @@ public class MusicUtil { @NonNull public static String getArtistInfoString(@NonNull final Context context, @NonNull final Artist artist) { - return artist.genres.size() != 0 ? artist.genres.get(0).name : ""; + return !artist.genres.isEmpty() ? artist.genres.get(0).name : ""; } @NonNull diff --git a/app/src/main/java/org/adrianvictor/geleia/util/NavigationUtil.java b/app/src/main/java/org/adrianvictor/geleia/util/NavigationUtil.java index 924c47ba..30460c35 100644 --- a/app/src/main/java/org/adrianvictor/geleia/util/NavigationUtil.java +++ b/app/src/main/java/org/adrianvictor/geleia/util/NavigationUtil.java @@ -12,6 +12,7 @@ import androidx.core.util.Pair; import org.adrianvictor.geleia.activities.LoginActivity; import org.adrianvictor.geleia.activities.MainActivity; import org.adrianvictor.geleia.activities.SelectActivity; +import org.adrianvictor.geleia.activities.UnreachableActivity; import org.adrianvictor.geleia.model.Album; import org.adrianvictor.geleia.model.Artist; import org.adrianvictor.geleia.model.Genre; @@ -61,6 +62,13 @@ public class NavigationUtil { context.startActivity(intent); } + public static void startUnreachable(Context context) { + final Intent intent = new Intent(context, UnreachableActivity.class); + + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + context.startActivity(intent); + } + public static void startSelect(Context context) { final Intent intent = new Intent(context, SelectActivity.class); diff --git a/app/src/main/java/org/adrianvictor/geleia/util/PlaylistUtil.java b/app/src/main/java/org/adrianvictor/geleia/util/PlaylistUtil.java index 9144b16a..731724ea 100644 --- a/app/src/main/java/org/adrianvictor/geleia/util/PlaylistUtil.java +++ b/app/src/main/java/org/adrianvictor/geleia/util/PlaylistUtil.java @@ -48,7 +48,7 @@ public class PlaylistUtil { PlaylistCreationRequest request = new PlaylistCreationRequest(); request.setUserId(App.getApiClient().getCurrentUserId()); request.setName(name); - if (ids.size() != 0) request.setItemIdList(ids); + if (!ids.isEmpty()) request.setItemIdList(ids); App.getApiClient().CreatePlaylist(request, new Response<>()); } diff --git a/app/src/main/java/org/adrianvictor/geleia/util/PreferenceUtil.java b/app/src/main/java/org/adrianvictor/geleia/util/PreferenceUtil.java index 1becdcb9..71ff53d5 100644 --- a/app/src/main/java/org/adrianvictor/geleia/util/PreferenceUtil.java +++ b/app/src/main/java/org/adrianvictor/geleia/util/PreferenceUtil.java @@ -448,7 +448,6 @@ public final class PreferenceUtil { }).collect(Collectors.toList()); } - @SuppressWarnings("SimplifyStreamApiCallChains") public void setCategories(List categories) { List values = categories.stream().map(category -> { return category.select ? category.toString() : category.toString().toLowerCase(); diff --git a/app/src/main/java/org/adrianvictor/geleia/views/IconImageView.java b/app/src/main/java/org/adrianvictor/geleia/views/IconImageView.java index 557797cf..6af3adc6 100644 --- a/app/src/main/java/org/adrianvictor/geleia/views/IconImageView.java +++ b/app/src/main/java/org/adrianvictor/geleia/views/IconImageView.java @@ -10,23 +10,27 @@ import org.adrianvictor.geleia.util.ThemeUtil; import org.adrianvictor.geleia.R; public class IconImageView extends AppCompatImageView { + public IconImageView(Context context) { super(context); - init(context); } public IconImageView(Context context, AttributeSet attrs) { super(context, attrs); - init(context); } public IconImageView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); - init(context); } - private void init(Context context) { - if (context == null) return; - setColorFilter(ThemeUtil.getColorResource(context, R.attr.iconColor), PorterDuff.Mode.SRC_IN); + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + init(); + } + + private void init() { + if (getContext() == null) return; + setColorFilter(ThemeUtil.getColorResource(getContext(), R.attr.iconColor), PorterDuff.Mode.SRC_IN); } } diff --git a/app/src/main/java/org/adrianvictor/geleia/views/shortcuts/DynamicShortcutManager.java b/app/src/main/java/org/adrianvictor/geleia/views/shortcuts/DynamicShortcutManager.java index ee889cd8..c45ab175 100644 --- a/app/src/main/java/org/adrianvictor/geleia/views/shortcuts/DynamicShortcutManager.java +++ b/app/src/main/java/org/adrianvictor/geleia/views/shortcuts/DynamicShortcutManager.java @@ -36,7 +36,7 @@ public class DynamicShortcutManager { } public void initDynamicShortcuts() { - if (shortcutManager.getDynamicShortcuts().size() == 0) { + if (shortcutManager.getDynamicShortcuts().isEmpty()) { shortcutManager.setDynamicShortcuts(getDefaultShortcuts()); } } diff --git a/app/src/main/res/drawable/ic_launcher_nodpi.xml b/app/src/main/res/drawable/ic_launcher_nodpi.xml index 1361edfa..a8b409b1 100644 --- a/app/src/main/res/drawable/ic_launcher_nodpi.xml +++ b/app/src/main/res/drawable/ic_launcher_nodpi.xml @@ -1,14 +1,4 @@ - - - - - - - - - - + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml index a807a132..61ff6cc1 100644 --- a/app/src/main/res/layout/activity_login.xml +++ b/app/src/main/res/layout/activity_login.xml @@ -19,134 +19,122 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" /> - - - - - - - - - - - - - - - - - - - - - - - + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent"> + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_unreachable.xml b/app/src/main/res/layout/activity_unreachable.xml new file mode 100644 index 00000000..ddc6f028 --- /dev/null +++ b/app/src/main/res/layout/activity_unreachable.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + +