From ff824d19aabb1df5d46a4b35de00b7d2e7ef913f Mon Sep 17 00:00:00 2001 From: birdbird <6892457-tzugen@users.noreply.gitlab.com> Date: Sun, 18 Dec 2022 10:55:13 +0000 Subject: [PATCH] Fix some crashes --- build.gradle | 1 + gradle/libs.versions.toml | 10 +- ultrasonic/build.gradle | 2 + .../DefaultMediaNotificationProvider2.java | 639 ------------------ .../ultrasonic/fragment/SettingsFragment.kt | 29 - .../playback/CustomNotificationProvider.kt | 7 +- .../ultrasonic/service/DownloadService.kt | 81 ++- .../moire/ultrasonic/service/DownloadTask.kt | 3 + .../org/moire/ultrasonic/util/Settings.kt | 4 +- .../org/moire/ultrasonic/util/Storage.kt | 2 + ultrasonic/src/main/res/values-de/strings.xml | 3 +- ultrasonic/src/main/res/values-es/strings.xml | 3 +- ultrasonic/src/main/res/values-nl/strings.xml | 3 +- .../src/main/res/values-pt-rBR/strings.xml | 3 +- ultrasonic/src/main/res/values/strings.xml | 3 +- 15 files changed, 70 insertions(+), 723 deletions(-) delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/service/DefaultMediaNotificationProvider2.java diff --git a/build.gradle b/build.gradle index efa20eed..0e2c097b 100644 --- a/build.gradle +++ b/build.gradle @@ -11,6 +11,7 @@ buildscript { google() mavenCentral() maven { url "https://plugins.gradle.org/m2/" } + maven { url 'https://jitpack.io' } } dependencies { classpath libs.gradle diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3b656efc..06a4ca75 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ ktlint = "0.43.2" ktlintGradle = "11.0.0" detekt = "1.22.0" preferences = "1.2.0" -media3 = "1.0.0-beta03" +media3 = "f3e450e783" androidSupport = "1.5.0" materialDesign = "1.6.1" @@ -64,9 +64,11 @@ navigationUiKtx = { module = "androidx.navigation:navigation-ui-ktx", ve navigationFeature = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigation" } navigationSafeArgs = { module = "androidx.navigation:navigation-safe-args-gradle-plugin", version.ref = "navigation"} preferences = { module = "androidx.preference:preference", version.ref = "preferences" } -media3exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } -media3okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" } -media3session = { module = "androidx.media3:media3-session", version.ref = "media3" } +media3common = { module = "com.github.androidx.media:media3-common", version.ref = "media3" } +media3exoplayer = { module = "com.github.androidx.media:media3-exoplayer", version.ref = "media3" } +media3datasource = { module = "com.github.androidx.media:media3-datasource", version.ref = "media3" } +media3okhttp = { module = "com.github.androidx.media:media3-datasource-okhttp", version.ref = "media3" } +media3session = { module = "com.github.androidx.media:media3-session", version.ref = "media3" } swipeRefresh = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swipeRefresh" } kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index f5cd922c..15f1a548 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -104,8 +104,10 @@ dependencies { implementation libs.viewModelKtx implementation libs.constraintLayout implementation libs.preferences + implementation libs.media3common implementation libs.media3exoplayer implementation libs.media3session + implementation libs.media3datasource implementation libs.media3okhttp implementation libs.swipeRefresh diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/DefaultMediaNotificationProvider2.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/DefaultMediaNotificationProvider2.java deleted file mode 100644 index 405d4fb2..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/DefaultMediaNotificationProvider2.java +++ /dev/null @@ -1,639 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License - */ -package org.moire.ultrasonic.service; - -import static androidx.media3.common.C.INDEX_UNSET; -import static androidx.media3.common.Player.COMMAND_INVALID; -import static androidx.media3.common.Player.COMMAND_PLAY_PAUSE; -import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT; -import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM; -import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS; -import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM; -import static androidx.media3.common.Player.COMMAND_STOP; -import static androidx.media3.common.Player.STATE_ENDED; -import static androidx.media3.common.util.Assertions.checkState; -import static androidx.media3.common.util.Assertions.checkStateNotNull; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.content.Context; -import android.graphics.Bitmap; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import androidx.annotation.DoNotInline; -import androidx.annotation.DrawableRes; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.annotation.StringRes; -import androidx.core.app.NotificationCompat; -import androidx.core.graphics.drawable.IconCompat; -import androidx.media3.common.C; -import androidx.media3.common.MediaMetadata; -import androidx.media3.common.Player; -import androidx.media3.common.util.Log; -import androidx.media3.common.util.UnstableApi; -import androidx.media3.common.util.Util; -import androidx.media3.session.CommandButton; -import androidx.media3.session.MediaController; -import androidx.media3.session.MediaNotification; -import androidx.media3.session.MediaSession; -import androidx.media3.session.MediaStyleNotificationHelper; -import androidx.media3.session.SessionCommand; - -import com.google.common.collect.ImmutableList; -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.errorprone.annotations.CanIgnoreReturnValue; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.ExecutionException; -import org.checkerframework.checker.nullness.qual.MonotonicNonNull; -import org.moire.ultrasonic.R; - -/** - * The default {@link MediaNotification.Provider}. - * - *

Actions

- * - * The following actions are included in the provided notifications: - * - * - * - *

Custom commands

- * - * Custom actions are sent to the session under the hood. You can receive them by overriding the - * session callback method {@link MediaSession.Callback#onCustomCommand(MediaSession, - * MediaSession.ControllerInfo, SessionCommand, Bundle)}. This is useful because starting with - * Android 13, the System UI notification sends commands directly to the session. So handling the - * custom commands on the session level allows you to handle them at the same callback for all API - * levels. - * - *

Drawables

- * - * The drawables used can be overridden by drawables with the same names defined the application. - * The drawables are: - * - * - * - *

String resources

- * - * String resources used can be overridden by resources with the same names defined the application. - * These are: - * - * - */ -@UnstableApi -public class DefaultMediaNotificationProvider2 implements MediaNotification.Provider { - - /** A builder for {@link DefaultMediaNotificationProvider2} instances. */ - public static final class Builder { - private final Context context; - private NotificationIdProvider notificationIdProvider; - private String channelId; - @StringRes private int channelNameResourceId; - private boolean built; - - /** - * Creates a builder. - * - * @param context Any {@link Context}. - */ - public Builder(Context context) { - this.context = context; - notificationIdProvider = session -> DEFAULT_NOTIFICATION_ID; - channelId = DEFAULT_CHANNEL_ID; - channelNameResourceId = DEFAULT_CHANNEL_NAME_RESOURCE_ID; - } - - /** - * Sets the {@link MediaNotification#notificationId} used for the created notifications. By - * default, this is set to {@link #DEFAULT_NOTIFICATION_ID}. - * - *

Overwrites anything set in {@link #setNotificationIdProvider(NotificationIdProvider)}. - * - * @param notificationId The notification ID. - * @return This builder. - */ - @CanIgnoreReturnValue - public Builder setNotificationId(int notificationId) { - this.notificationIdProvider = session -> notificationId; - return this; - } - - /** - * Sets the provider for the {@link MediaNotification#notificationId} used for the created - * notifications. By default, this is set to a provider that always returns {@link - * #DEFAULT_NOTIFICATION_ID}. - * - *

Overwrites anything set in {@link #setNotificationId(int)}. - * - * @param notificationIdProvider The notification ID provider. - * @return This builder. - */ - @CanIgnoreReturnValue - public Builder setNotificationIdProvider(NotificationIdProvider notificationIdProvider) { - this.notificationIdProvider = notificationIdProvider; - return this; - } - - /** - * Sets the ID of the {@link NotificationChannel} on which created notifications are posted on. - * By default, this is set to {@link #DEFAULT_CHANNEL_ID}. - * - * @param channelId The channel ID. - * @return This builder. - */ - @CanIgnoreReturnValue - public Builder setChannelId(String channelId) { - this.channelId = channelId; - return this; - } - - /** - * Sets the name of the {@link NotificationChannel} on which created notifications are posted - * on. By default, this is set to {@link #DEFAULT_CHANNEL_NAME_RESOURCE_ID}. - * - * @param channelNameResourceId The string resource ID with the channel name. - * @return This builder. - */ - @CanIgnoreReturnValue - public Builder setChannelName(@StringRes int channelNameResourceId) { - this.channelNameResourceId = channelNameResourceId; - return this; - } - - /** - * Builds the {@link DefaultMediaNotificationProvider2}. The method can be called at most once. - */ - public DefaultMediaNotificationProvider2 build() { - checkState(!built); - DefaultMediaNotificationProvider2 provider = new DefaultMediaNotificationProvider2(this); - built = true; - return provider; - } - } - - /** - * Provides notification IDs for posting media notifications for given media sessions. - * - * @see Builder#setNotificationIdProvider(NotificationIdProvider) - */ - public interface NotificationIdProvider { - /** Returns the notification ID for the media notification of the given session. */ - int getNotificationId(MediaSession mediaSession); - } - - /** - * An extras key that can be used to define the index of a {@link CommandButton} in {@linkplain - * Notification.MediaStyle#setShowActionsInCompactView(int...) compact view}. - */ - public static final String COMMAND_KEY_COMPACT_VIEW_INDEX = - "androidx.media3.session.command.COMPACT_VIEW_INDEX"; - - /** The default ID used for the {@link MediaNotification#notificationId}. */ - public static final int DEFAULT_NOTIFICATION_ID = 1001; - /** - * The default ID used for the {@link NotificationChannel} on which created notifications are - * posted on. - */ - public static final String DEFAULT_CHANNEL_ID = "default_channel_id"; - /** - * The default name used for the {@link NotificationChannel} on which created notifications are - * posted on. - */ - @StringRes - public static final int DEFAULT_CHANNEL_NAME_RESOURCE_ID = - R.string.default_notification_channel_name; - - private static final String TAG = "NotificationProvider"; - - private final Context context; - private final NotificationIdProvider notificationIdProvider; - private final String channelId; - @StringRes private final int channelNameResourceId; - private final NotificationManager notificationManager; - // Cache the last bitmap load request to avoid reloading the bitmap again, particularly useful - // when showing a notification for the same item (e.g. when switching from playing to paused). - private final Handler mainHandler; - - private @MonotonicNonNull OnBitmapLoadedFutureCallback pendingOnBitmapLoadedFutureCallback; - @DrawableRes private int smallIconResourceId; - - public DefaultMediaNotificationProvider2(Builder builder) { - this.context = builder.context; - this.notificationIdProvider = builder.notificationIdProvider; - this.channelId = builder.channelId; - this.channelNameResourceId = builder.channelNameResourceId; - notificationManager = - checkStateNotNull( - (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)); - mainHandler = new Handler(Looper.getMainLooper()); - smallIconResourceId = R.drawable.media3_notification_small_icon; - } - - // MediaNotification.Provider implementation - - @Override - public final MediaNotification createNotification( - MediaSession mediaSession, - ImmutableList customLayout, - MediaNotification.ActionFactory actionFactory, - Callback onNotificationChangedCallback) { - ensureNotificationChannel(); - - Player player = mediaSession.getPlayer(); - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId); - int notificationId = notificationIdProvider.getNotificationId(mediaSession); - - MediaStyleNotificationHelper.MediaStyle mediaStyle = new MediaStyleNotificationHelper.MediaStyle(mediaSession); - int[] compactViewIndices = - addNotificationActions( - mediaSession, - getMediaButtons( - player.getAvailableCommands(), - customLayout, - /* showPauseButton= */ player.getPlayWhenReady() - && player.getPlaybackState() != STATE_ENDED), - builder, - actionFactory); - mediaStyle.setShowActionsInCompactView(compactViewIndices); - - // Set metadata info in the notification. - MediaMetadata metadata = player.getMediaMetadata(); - builder - .setContentTitle(getNotificationContentTitle(metadata)) - .setContentText(getNotificationContentText(metadata)); - @Nullable - ListenableFuture bitmapFuture = - mediaSession.getBitmapLoader().loadBitmapFromMetadata(metadata); - if (bitmapFuture != null) { - if (pendingOnBitmapLoadedFutureCallback != null) { - pendingOnBitmapLoadedFutureCallback.discardIfPending(); - } - if (bitmapFuture.isDone()) { - try { - builder.setLargeIcon(Futures.getDone(bitmapFuture)); - } catch (ExecutionException e) { - Log.w(TAG, "Failed to load bitmap", e); - } - } else { - pendingOnBitmapLoadedFutureCallback = - new OnBitmapLoadedFutureCallback( - notificationId, builder, onNotificationChangedCallback); - Futures.addCallback( - bitmapFuture, - pendingOnBitmapLoadedFutureCallback, - // This callback must be executed on the next looper iteration, after this method has - // returned a media notification. - mainHandler::post); - } - } - - if (player.isCommandAvailable(COMMAND_STOP) || Util.SDK_INT < 21) { - // We must include a cancel intent for pre-L devices. - mediaStyle.setCancelButtonIntent( - actionFactory.createMediaActionPendingIntent(mediaSession, COMMAND_STOP)); - } - - long playbackStartTimeMs = getPlaybackStartTimeEpochMs(player); - boolean displayElapsedTimeWithChronometer = playbackStartTimeMs != C.TIME_UNSET; - builder - .setWhen(playbackStartTimeMs) - .setShowWhen(displayElapsedTimeWithChronometer) - .setUsesChronometer(displayElapsedTimeWithChronometer); - - Notification notification = - builder - .setContentIntent(mediaSession.getSessionActivity()) - .setDeleteIntent( - actionFactory.createMediaActionPendingIntent(mediaSession, COMMAND_STOP)) - .setOnlyAlertOnce(true) - .setSmallIcon(smallIconResourceId) - .setStyle(mediaStyle) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setOngoing(false) - .build(); - return new MediaNotification(notificationId, notification); - } - - @Override - public final boolean handleCustomCommand(MediaSession session, String action, Bundle extras) { - // Make the custom action being delegated to the session as a custom session command. - return false; - } - - // Other methods - - /** - * Sets the small icon of the notification which is also shown in the system status bar. - * - * @see NotificationCompat.Builder#setSmallIcon(int) - * @param smallIconResourceId The resource id of the small icon. - */ - public final void setSmallIcon(@DrawableRes int smallIconResourceId) { - this.smallIconResourceId = smallIconResourceId; - } - - /** - * Returns the ordered list of {@linkplain CommandButton command buttons} to be used to build the - * notification. - * - *

This method is called each time a new notification is built. - * - *

Override this method to customize the buttons on the notification. Commands of the buttons - * returned by this method must be contained in {@link MediaController#getAvailableCommands()}. - * - *

By default, the notification shows {@link Player#COMMAND_PLAY_PAUSE} in {@linkplain - * Notification.MediaStyle#setShowActionsInCompactView(int...) compact view}. This can be - * customized by defining the index of the command in compact view of up to 3 commands in their - * extras with key {@link DefaultMediaNotificationProvider2#COMMAND_KEY_COMPACT_VIEW_INDEX}. - * - *

To make the custom layout and commands work, you need to {@linkplain - * MediaSession#setCustomLayout(List) set the custom layout of commands} and add the custom - * commands to the available commands when a controller {@linkplain - * MediaSession.Callback#onConnect(MediaSession, MediaSession.ControllerInfo) connects to the - * session}. Controllers that connect after you called {@link MediaSession#setCustomLayout(List)} - * need the custom command set in {@link MediaSession.Callback#onPostConnect(MediaSession, - * MediaSession.ControllerInfo)} also. - * - * @param playerCommands The available player commands. - * @param customLayout The {@linkplain MediaSession#setCustomLayout(List) custom layout of - * commands}. - * @param showPauseButton Whether the notification should show a pause button (e.g., because the - * player is currently playing content), otherwise show a play button to start playback. - * @return The ordered list of command buttons to be placed on the notification. - */ - protected List getMediaButtons( - Player.Commands playerCommands, List customLayout, boolean showPauseButton) { - // Skip to previous action. - List commandButtons = new ArrayList<>(); - if (playerCommands.containsAny(COMMAND_SEEK_TO_PREVIOUS, COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)) { - Bundle commandButtonExtras = new Bundle(); - commandButtonExtras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, INDEX_UNSET); - commandButtons.add( - new CommandButton.Builder() - .setPlayerCommand(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) - .setIconResId(R.drawable.media3_notification_seek_to_previous) - .setDisplayName( - context.getString(R.string.media3_controls_seek_to_previous_description)) - .setExtras(commandButtonExtras) - .build()); - } - if (playerCommands.contains(COMMAND_PLAY_PAUSE)) { - Bundle commandButtonExtras = new Bundle(); - commandButtonExtras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, INDEX_UNSET); - commandButtons.add( - new CommandButton.Builder() - .setPlayerCommand(COMMAND_PLAY_PAUSE) - .setIconResId( - showPauseButton - ? R.drawable.media3_notification_pause - : R.drawable.media3_notification_play) - .setExtras(commandButtonExtras) - .setDisplayName( - showPauseButton - ? context.getString(R.string.media3_controls_pause_description) - : context.getString(R.string.media3_controls_play_description)) - .build()); - } - // Skip to next action. - if (playerCommands.containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)) { - Bundle commandButtonExtras = new Bundle(); - commandButtonExtras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, INDEX_UNSET); - commandButtons.add( - new CommandButton.Builder() - .setPlayerCommand(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) - .setIconResId(R.drawable.media3_notification_seek_to_next) - .setExtras(commandButtonExtras) - .setDisplayName(context.getString(R.string.media3_controls_seek_to_next_description)) - .build()); - } - for (int i = 0; i < customLayout.size(); i++) { - CommandButton button = customLayout.get(i); - if (button.sessionCommand != null - && button.sessionCommand.commandCode == SessionCommand.COMMAND_CODE_CUSTOM) { - commandButtons.add(button); - } - } - return commandButtons; - } - - /** - * Adds the media buttons to the notification builder for the given action factory. - * - *

The list of {@code mediaButtons} is the list resulting from {@link #getMediaButtons( - * Player.Commands, List, boolean)}. - * - *

Override this method to customize how the media buttons {@linkplain - * NotificationCompat.Builder#addAction(NotificationCompat.Action) are added} to the notification - * and define which actions are shown in compact view by returning the indices of the buttons to - * be shown in compact view. - * - *

By default, {@link Player#COMMAND_PLAY_PAUSE} is shown in compact view, unless some of the - * buttons are marked with {@link DefaultMediaNotificationProvider2#COMMAND_KEY_COMPACT_VIEW_INDEX} - * to declare the index in compact view of the given command button in the button extras. - * - * @param mediaSession The media session to which the actions will be sent. - * @param mediaButtons The command buttons to be included in the notification. - * @param builder The builder to add the actions to. - * @param actionFactory The actions factory to be used to build notifications. - * @return The indices of the buttons to be {@linkplain - * Notification.MediaStyle#setShowActionsInCompactView(int...) used in compact view of the - * notification}. - */ - protected int[] addNotificationActions( - MediaSession mediaSession, - List mediaButtons, - NotificationCompat.Builder builder, - MediaNotification.ActionFactory actionFactory) { - int[] compactViewIndices = new int[3]; - Arrays.fill(compactViewIndices, INDEX_UNSET); - int compactViewCommandCount = 0; - for (int i = 0; i < mediaButtons.size(); i++) { - CommandButton commandButton = mediaButtons.get(i); - if (commandButton.sessionCommand != null) { - builder.addAction( - actionFactory.createCustomActionFromCustomCommandButton(mediaSession, commandButton)); - } else { - checkState(commandButton.playerCommand != COMMAND_INVALID); - builder.addAction( - actionFactory.createMediaAction( - mediaSession, - IconCompat.createWithResource(context, commandButton.iconResId), - commandButton.displayName, - commandButton.playerCommand)); - } - if (compactViewCommandCount == 3) { - continue; - } - int compactViewIndex = - commandButton.extras.getInt( - COMMAND_KEY_COMPACT_VIEW_INDEX, /* defaultValue= */ INDEX_UNSET); - if (compactViewIndex >= 0 && compactViewIndex < compactViewIndices.length) { - compactViewCommandCount++; - compactViewIndices[compactViewIndex] = i; - } else if (commandButton.playerCommand == COMMAND_PLAY_PAUSE - && compactViewCommandCount == 0) { - // If there is no custom configuration we use the play/pause action in compact view. - compactViewIndices[0] = i; - } - } - for (int i = 0; i < compactViewIndices.length; i++) { - if (compactViewIndices[i] == INDEX_UNSET) { - compactViewIndices = Arrays.copyOf(compactViewIndices, i); - break; - } - } - return compactViewIndices; - } - - /** - * Returns the content title to be used to build the notification. - * - *

This method is called each time a new notification is built. - * - *

Override this method to customize which field of {@link MediaMetadata} is used for content - * title of the notification. - * - *

By default, the notification shows {@link MediaMetadata#title} as content title. - * - * @param metadata The media metadata from which content title is fetched. - * @return Notification content title. - */ - @Nullable - protected CharSequence getNotificationContentTitle(MediaMetadata metadata) { - return metadata.title; - } - - /** - * Returns the content text to be used to build the notification. - * - *

This method is called each time a new notification is built. - * - *

Override this method to customize which field of {@link MediaMetadata} is used for content - * text of the notification. - * - *

By default, the notification shows {@link MediaMetadata#artist} as content text. - * - * @param metadata The media metadata from which content text is fetched. - * @return Notification content text. - */ - @Nullable - protected CharSequence getNotificationContentText(MediaMetadata metadata) { - return metadata.artist; - } - - private void ensureNotificationChannel() { - if (Util.SDK_INT < 26 || notificationManager.getNotificationChannel(channelId) != null) { - return; - } - Api26.createNotificationChannel( - notificationManager, channelId, context.getString(channelNameResourceId)); - } - - private static long getPlaybackStartTimeEpochMs(Player player) { - // Changing "showWhen" causes notification flicker if SDK_INT < 21. - if (Util.SDK_INT >= 21 - && player.isPlaying() - && !player.isPlayingAd() - && !player.isCurrentMediaItemDynamic() - && player.getPlaybackParameters().speed == 1f) { - return System.currentTimeMillis() - player.getContentPosition(); - } else { - return C.TIME_UNSET; - } - } - - private static class OnBitmapLoadedFutureCallback implements FutureCallback { - private final int notificationId; - private final NotificationCompat.Builder builder; - private final Callback onNotificationChangedCallback; - - private boolean discarded; - - public OnBitmapLoadedFutureCallback( - int notificationId, - NotificationCompat.Builder builder, - Callback onNotificationChangedCallback) { - this.notificationId = notificationId; - this.builder = builder; - this.onNotificationChangedCallback = onNotificationChangedCallback; - } - - public void discardIfPending() { - discarded = true; - } - - @Override - public void onSuccess(Bitmap result) { - if (!discarded) { - builder.setLargeIcon(result); - onNotificationChangedCallback.onNotificationChanged( - new MediaNotification(notificationId, builder.build())); - } - } - - @Override - public void onFailure(Throwable t) { - if (!discarded) { - Log.d(TAG, "Failed to load bitmap", t); - } - } - } - - @RequiresApi(26) - private static class Api26 { - @DoNotInline - public static void createNotificationChannel( - NotificationManager notificationManager, String channelId, String channelName) { - NotificationChannel channel = - new NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW); - if (Util.SDK_INT <= 27) { - // API 28+ will automatically hide the app icon 'badge' for notifications using - // Notification.MediaStyle, but we have to manually hide it for APIs 26 (when badges were - // added) and 27. - channel.setShowBadge(false); - } - notificationManager.createNotificationChannel(channel); - } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt index ccfce111..016765c1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt @@ -6,16 +6,11 @@ import android.content.DialogInterface import android.content.Intent import android.content.SharedPreferences import android.content.SharedPreferences.OnSharedPreferenceChangeListener -import android.graphics.Color -import android.graphics.Typeface import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.DocumentsContract import android.provider.SearchRecentSuggestions -import android.text.SpannableString -import android.text.style.ForegroundColorSpan -import android.text.style.StyleSpan import android.view.View import androidx.annotation.StringRes import androidx.preference.CheckBoxPreference @@ -89,34 +84,11 @@ class SettingsFragment : customCacheLocation = findPreference(getString(R.string.setting_key_custom_cache_location)) cacheLocation = findPreference(getString(R.string.setting_key_cache_location)) - setupTextColors() setupClearSearchPreference() setupCacheLocationPreference() setupBluetoothDevicePreferences() } - private fun setupTextColors(enabled: Boolean = shouldUseId3Tags) { - val firstPart = getString(R.string.settings_use_id3_offline_warning) - var secondPart = getString(R.string.settings_use_id3_offline_summary) - - // Little hack to circumvent a bug in Android. If we just change the color, - // the text is not refreshed. If we also change the string, it is refreshed. - if (enabled) secondPart += " " - - val color = if (enabled) "#bd5164" else "#813b48" - - Timber.i(color) - - val warning = SpannableString(firstPart + "\n" + secondPart) - warning.setSpan( - ForegroundColorSpan(Color.parseColor(color)), 0, firstPart.length, 0 - ) - warning.setSpan( - StyleSpan(Typeface.BOLD), 0, firstPart.length, 0 - ) - useId3TagsOffline?.summary = warning - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -205,7 +177,6 @@ class SettingsFragment : val enabled = sharedPreferences.getBoolean(key, false) showArtistPicture?.isEnabled = enabled useId3TagsOffline?.isEnabled = enabled - setupTextColors(enabled) } getString(R.string.setting_key_theme) -> { RxBus.themeChangedEventPublisher.onNext(Unit) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CustomNotificationProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CustomNotificationProvider.kt index b6d5c809..10fa2094 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CustomNotificationProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CustomNotificationProvider.kt @@ -12,19 +12,20 @@ import androidx.media3.common.HeartRating import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.MediaNotification import androidx.media3.session.MediaSession import androidx.media3.session.SessionCommand import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.moire.ultrasonic.R -import org.moire.ultrasonic.service.DefaultMediaNotificationProvider2 import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.util.toTrack @UnstableApi -class CustomNotificationProvider(ctx: Context?) : - DefaultMediaNotificationProvider2(Builder(ctx)), KoinComponent { +class CustomNotificationProvider(ctx: Context) : + DefaultMediaNotificationProvider(ctx), + KoinComponent { /* * It is currently not possible to edit a MediaItem after creation so the isRated value diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt index 8899f382..e574c24a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt @@ -23,7 +23,8 @@ import androidx.lifecycle.MutableLiveData import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.MoreExecutors import com.google.common.util.concurrent.SettableFuture -import java.util.PriorityQueue +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.PriorityBlockingQueue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -135,19 +136,19 @@ class DownloadService : Service(), KoinComponent { var listChanged = false // Fill up active List with waiting tasks - while (activelyDownloading.size < Settings.parallelDownloads && downloadQueue.size > 0) { + while (activeDownloads.size < Settings.parallelDownloads && downloadQueue.peek() != null) { // Use poll() instead of remove() which throws an Exception if there is no element. val track: DownloadableTrack = downloadQueue.poll() ?: continue val downloadTask = DownloadTask(track, scope!!, ::downloadStateChangedCallback) - activelyDownloading[track] = downloadTask + activeDownloads[track.id] = downloadTask FileUtil.createDirectoryForParent(track.pinnedFile) downloadTask.start() listChanged = true } // Stop Executor service when done downloading - if (activelyDownloading.isEmpty()) { + if (activeDownloads.isEmpty()) { CacheCleaner().cleanSpace() stopSelf() } @@ -175,14 +176,14 @@ class DownloadService : Service(), KoinComponent { postState(item.track, downloadState, progress) if (downloadState.isFinalState()) { - activelyDownloading.remove(item) + activeDownloads.remove(item.id) processNextTracks() } when (downloadState) { DownloadState.FAILED -> { downloadQueue.remove(item) - failedList.add(item) + failedList[item.id] = item } DownloadState.RETRYING -> { item.tryCount++ @@ -194,7 +195,7 @@ class DownloadService : Service(), KoinComponent { private fun updateLiveData() { val temp: MutableList = ArrayList() - temp.addAll(activelyDownloading.keys.map { x -> x.track }) + temp.addAll(activeDownloads.values.map { it.track.track }) temp.addAll(downloadQueue.map { x -> x.track }) observableDownloads.postValue(temp.distinct().sorted()) } @@ -205,10 +206,10 @@ class DownloadService : Service(), KoinComponent { postState(downloadQueue.remove().track, DownloadState.IDLE) } // Cancel all active downloads - for (download in activelyDownloading) { + for (download in activeDownloads) { download.value.cancel() } - activelyDownloading.clear() + activeDownloads.clear() updateLiveData() } @@ -280,9 +281,9 @@ class DownloadService : Service(), KoinComponent { private var startFuture: SettableFuture? = null - private val downloadQueue = PriorityQueue() - private val activelyDownloading = mutableMapOf() - private val failedList = mutableListOf() + private val downloadQueue = PriorityBlockingQueue() + private val activeDownloads = ConcurrentHashMap() + private val failedList = ConcurrentHashMap() // The generic list models expect a LiveData, so even though we are using Rx for many events // surrounding playback the list of Downloads is published as LiveData. @@ -319,17 +320,15 @@ class DownloadService : Service(), KoinComponent { // Update Pinned flag of items in progress downloadQueue.filter { item -> tracks.any { it.id == item.id } } .forEach { it.pinned = save } - activelyDownloading.filter { item -> tracks.any { it.id == item.key.id } } - .forEach { it.key.pinned = save } - failedList.filter { item -> tracks.any { it.id == item.id } } - .forEach { it.pinned = save } + tracks.forEach { + activeDownloads[it.id]?.track?.pinned = save + } + tracks.forEach { + failedList[it.id]?.pinned = save + } filteredTracks = filteredTracks.filter { - !downloadQueue.any { t -> - t.track.id == it.id - } && !activelyDownloading.any { t -> - t.key.track.id == it.id - } + !downloadQueue.contains(it.id) && !activeDownloads.contains(it.id) } // The remainder tracks should be added to the download queue @@ -361,8 +360,8 @@ class DownloadService : Service(), KoinComponent { fun delete(track: Track) { - downloadQueue.singleOrNull { it.id == track.id }?.let { downloadQueue.remove(it) } - failedList.singleOrNull { it.id == track.id }?.let { downloadQueue.remove(it) } + downloadQueue.get(track.id)?.let { downloadQueue.remove(it) } + failedList[track.id]?.let { downloadQueue.remove(it) } cancelDownload(track) Storage.delete(track.getPartialFile()) @@ -375,9 +374,9 @@ class DownloadService : Service(), KoinComponent { fun unpin(track: Track) { // Update Pinned flag of items in progress - downloadQueue.singleOrNull { it.id == track.id }?.pinned = false - activelyDownloading.keys.singleOrNull { it.id == track.id }?.pinned = false - failedList.singleOrNull { it.id == track.id }?.pinned = false + downloadQueue.get(track.id)?.pinned = false + activeDownloads[track.id]?.track?.pinned = false + failedList[track.id]?.pinned = false val pinnedFile = track.getPinnedFile() if (!Storage.isPathExists(pinnedFile)) return @@ -393,17 +392,17 @@ class DownloadService : Service(), KoinComponent { @Suppress("ReturnCount") fun getDownloadState(track: Track): DownloadState { - if (Storage.isPathExists(track.getCompleteFile())) return DownloadState.DONE - if (Storage.isPathExists(track.getPinnedFile())) return DownloadState.PINNED - if (activelyDownloading.any { it.key.id == track.id }) return DownloadState.QUEUED - if (downloadQueue.any { it.id == track.id }) return DownloadState.QUEUED + if (activeDownloads.contains(track.id)) return DownloadState.QUEUED + if (downloadQueue.contains(track.id)) return DownloadState.QUEUED - val key = activelyDownloading.keys.firstOrNull { it.track.id == track.id } - if (key != null) { - if (key.tryCount > 0) return DownloadState.RETRYING + val downloadableTrack = activeDownloads[track.id]?.track + if (downloadableTrack != null) { + if (downloadableTrack.tryCount > 0) return DownloadState.RETRYING return DownloadState.DOWNLOADING } - if (failedList.any { it.track.id == track.id }) return DownloadState.FAILED + if (failedList[track.id] != null) return DownloadState.FAILED + if (Storage.isPathExists(track.getCompleteFile())) return DownloadState.DONE + if (Storage.isPathExists(track.getPinnedFile())) return DownloadState.PINNED return DownloadState.IDLE } @@ -417,8 +416,7 @@ class DownloadService : Service(), KoinComponent { } private fun cancelDownload(track: Track) { - val key = activelyDownloading.keys.singleOrNull { it.track.id == track.id } ?: return - activelyDownloading[key]?.cancel() + activeDownloads[track.id]?.cancel() } private fun postState(track: Track, state: DownloadState, progress: Int? = null) { @@ -447,5 +445,16 @@ class DownloadService : Service(), KoinComponent { context.startService(intent) } } + + fun PriorityBlockingQueue.get(id: String): DownloadableTrack? { + for (el in this) { + if (el.id == id) return el + } + return null + } + + fun PriorityBlockingQueue.contains(id: String): Boolean { + return (this.get(id) != null) + } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt index f978a337..e22d3c54 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt @@ -49,6 +49,9 @@ class DownloadTask( private var outputStream: OutputStream? = null private var lastPostTime: Long = 0 + val track: DownloadableTrack + get() = item + private fun checkIfExists(): Boolean { if (Storage.isPathExists(item.pinnedFile)) { Timber.i("%s already exists. Skipping.", item.pinnedFile) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt index d98379ea..55fa7d3f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt @@ -172,11 +172,11 @@ object Settings { // Normally you don't need to use these Settings directly, // use ActiveServerProvider.isID3Enabled() instead @JvmStatic - var shouldUseId3Tags by BooleanSetting(getKey(R.string.setting_key_id3_tags), false) + var shouldUseId3Tags by BooleanSetting(getKey(R.string.setting_key_id3_tags), true) // See comment above. @JvmStatic - var useId3TagsOffline by BooleanSetting(getKey(R.string.setting_key_id3_tags_offline), false) + var useId3TagsOffline by BooleanSetting(getKey(R.string.setting_key_id3_tags_offline), true) var activeServer by IntSetting(getKey(R.string.setting_key_server_instance), -1) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt index 6a5c1ff0..73022b81 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt @@ -70,6 +70,8 @@ object Storage { } catch (ignored: FileAlreadyExistsException) { // Play console has revealed a crash when for some reason both files exist delete(pathFrom.path) + } catch (ignored: Exception) { + // Ignore any other exceptions, such as NoSuchFileException etc. } } diff --git a/ultrasonic/src/main/res/values-de/strings.xml b/ultrasonic/src/main/res/values-de/strings.xml index f79fc727..2e0efda9 100644 --- a/ultrasonic/src/main/res/values-de/strings.xml +++ b/ultrasonic/src/main/res/values-de/strings.xml @@ -313,8 +313,7 @@ Durchsuchen von ID3-Tags Nutze ID3 Tag Methode anstatt Dateisystem-Methode ID3 Methode auch offline nutzen - Experimentell: Falls aktiviert, werden nur Lieder angezeigt die mit Ultrasonic 4.0 oder höher heruntergeladen wurden. - Ältere Downloads haben nicht unbedingt die notwendigen Metadaten heruntergeladen. Du kannst zwischen dem Anheft- und Downloadmodus wechseln um die fehlenden Metadaten herunter zu laden. + Falls aktiviert, werden nur Lieder angezeigt die mit Ultrasonic 4.0 oder höher heruntergeladen wurden. Ältere Downloads haben nicht die notwendigen Metadaten heruntergeladen. Du kannst zwischen dem Anheft- und Downloadmodus wechseln um die fehlenden Metadaten herunter zu laden. Zeige Bilder in der Liste der Künstler*innen an. Wenn Künstler*innen Bilder zugeordnet sind werden diese in der Liste angezeigt Film diff --git a/ultrasonic/src/main/res/values-es/strings.xml b/ultrasonic/src/main/res/values-es/strings.xml index 28ac0057..eb39e290 100644 --- a/ultrasonic/src/main/res/values-es/strings.xml +++ b/ultrasonic/src/main/res/values-es/strings.xml @@ -316,8 +316,7 @@ Navegar usando las etiquetas ID3 Usar el método de etiquetas ID3 en lugar del método basado en el sistema de ficheros Utilizar etiquetas ID3 también cuando se está fuera de línea - Experimental: Si activas este ajuste sólo se mostrará la música que hayas descargado con Ultrasonic 4.0 o posterior. - Las descargas anteriores no tienen los metadatos necesarios descargados. Puedes alternar entre el modo anclar y guardar para activar la descarga de los metadatos que faltan. + Si activas este ajuste sólo se mostrará la música que hayas descargado con Ultrasonic 4.0 o posterior. Las descargas anteriores no tienen los metadatos necesarios descargados. Puedes alternar entre el modo anclar y guardar para activar la descarga de los metadatos que faltan. Mostrar la imagen del artista en la lista de artistas Muestra la imagen del artista en la lista de artistas si está disponible Vídeo diff --git a/ultrasonic/src/main/res/values-nl/strings.xml b/ultrasonic/src/main/res/values-nl/strings.xml index f3777df4..fbecee9a 100644 --- a/ultrasonic/src/main/res/values-nl/strings.xml +++ b/ultrasonic/src/main/res/values-nl/strings.xml @@ -316,8 +316,7 @@ Navigeren met behulp van ID3-labels ID3-labels gebruiken in plaats van systeemlabels ID3-methoe ook in offline-modus gebruiken - Experimenteel: als je deze instelling inschakelt, dan wordt er alleen muziek getoond die gedownload is met versie 4.0 of hoger. - Oudere downloads beschikken niet over de benodigde metagegevens. Je kunt tussen modi schakelen om de ontbrekende metagegevens op te halen. + Als je deze instelling inschakelt, dan wordt er alleen muziek getoond die gedownload is met versie 4.0 of hoger. Oudere downloads beschikken niet over de benodigde metagegevens. Je kunt tussen modi schakelen om de ontbrekende metagegevens op te halen. Artiestfoto tonen op artiestenlijst Toont de artiestfoto op de artiestenlijst (indien beschikbaar) Video diff --git a/ultrasonic/src/main/res/values-pt-rBR/strings.xml b/ultrasonic/src/main/res/values-pt-rBR/strings.xml index bfe1c190..3d076e3e 100644 --- a/ultrasonic/src/main/res/values-pt-rBR/strings.xml +++ b/ultrasonic/src/main/res/values-pt-rBR/strings.xml @@ -314,8 +314,7 @@ Navegar Usando Etiquetas ID3 Usar as etiquetas ID3 ao invés do sistema de arquivos Usar método ID3 quando offline também - Experimental: Se você ativar esta configuração, ela mostrará apenas a música que você baixou com o Ultrasonic 4.0 ou posterior. - Os downloads anteriores não têm os metadados necessários baixados. Você pode alternar entre os modos Pin e Salvar para acionar o download dos metadados ausentes. + Se você ativar esta configuração, ela mostrará apenas a música que você baixou com o Ultrasonic 4.0 ou posterior. Os downloads anteriores não têm os metadados necessários baixados. Você pode alternar entre os modos Pin e Salvar para acionar o download dos metadados ausentes. Mostrar Foto do Artista na Lista Mostrar a imagem do artista na lista de artistas, se disponível Vídeo diff --git a/ultrasonic/src/main/res/values/strings.xml b/ultrasonic/src/main/res/values/strings.xml index c1f88922..f4961f15 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -316,8 +316,7 @@ Browse Using ID3 Tags Use ID3 tag methods instead of file system based methods Use ID3 method also when offline - Experimental: If you enable this Setting it will only show the music that you have downloaded with Ultrasonic 4.0 or later. - Earlier downloads don\'t have the necessary metadata downloaded. You can toggle between Pin and Save mode to trigger the download of the missing metadata. + If you enable this Setting it will only show the music that you have downloaded with Ultrasonic 4.0 or later. Earlier downloads don\'t have the necessary metadata downloaded. You can toggle between Pin and Save mode to trigger the download of the missing metadata. Show artist picture in artist list Displays the artist picture in the artist list if available Video