diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/TvUtil.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/TvUtil.java deleted file mode 100644 index 37ef3eca62..0000000000 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/TvUtil.java +++ /dev/null @@ -1,296 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.dolphinemu.dolphinemu.utils; - -import android.annotation.TargetApi; -import android.app.job.JobInfo; -import android.app.job.JobScheduler; -import android.content.ComponentName; -import android.content.ContentResolver; -import android.content.Context; -import android.content.pm.PackageManager; -import android.content.res.Resources; -import android.database.Cursor; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Canvas; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.VectorDrawable; -import android.net.Uri; -import android.os.Build; -import android.os.PersistableBundle; -import android.util.Log; - -import androidx.annotation.AnyRes; -import androidx.annotation.NonNull; -import androidx.tvprovider.media.tv.Channel; -import androidx.tvprovider.media.tv.TvContractCompat; - -import org.dolphinemu.dolphinemu.model.GameFile; -import org.dolphinemu.dolphinemu.model.HomeScreenChannel; -import org.dolphinemu.dolphinemu.services.SyncChannelJobService; -import org.dolphinemu.dolphinemu.services.SyncProgramsJobService; -import org.dolphinemu.dolphinemu.ui.platform.PlatformTab; - -import java.io.File; -import java.io.FileNotFoundException; -import java.util.ArrayList; -import java.util.List; - -import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION; -import static androidx.core.content.FileProvider.getUriForFile; - -/** - * Assists in TV related services, e.g., home screen channels - */ -public class TvUtil -{ - private static final String TAG = "TvUtil"; - private static final long CHANNEL_JOB_ID_OFFSET = 1000; - - private static final String[] CHANNELS_PROJECTION = { - TvContractCompat.Channels._ID, - TvContractCompat.Channels.COLUMN_DISPLAY_NAME, - TvContractCompat.Channels.COLUMN_BROWSABLE, - TvContractCompat.Channels.COLUMN_APP_LINK_INTENT_URI - }; - private static final String LEANBACK_PACKAGE = "com.google.android.tvlauncher"; - - public static int getNumberOfChannels(Context context) - { - Cursor cursor = - context.getContentResolver() - .query( - TvContractCompat.Channels.CONTENT_URI, - CHANNELS_PROJECTION, - null, - null, - null); - return cursor != null ? cursor.getCount() : 0; - } - - public static List getAllChannels(Context context) - { - List channels = new ArrayList<>(); - Cursor cursor = - context.getContentResolver() - .query( - TvContractCompat.Channels.CONTENT_URI, - CHANNELS_PROJECTION, - null, - null, - null); - if (cursor != null && cursor.moveToFirst()) - { - do - { - channels.add(Channel.fromCursor(cursor)); - } - while (cursor.moveToNext()); - } - return channels; - } - - public static Channel getChannelById(Context context, long channelId) - { - for (Channel channel : getAllChannels(context)) - { - if (channel.getId() == channelId) - { - return channel; - } - } - return null; - } - - /** - * Updates all Leanback homescreen channels - */ - public static void updateAllChannels(Context context) - { - if (Build.VERSION.SDK_INT < 26) - return; - for (Channel channel : getAllChannels(context)) - { - context.getContentResolver() - .update( - TvContractCompat.buildChannelUri(channel.getId()), - channel.toContentValues(), - null, - null); - } - } - - public static Uri getUriToResource(Context context, @AnyRes int resId) - throws Resources.NotFoundException - { - Resources res = context.getResources(); - return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + - "://" + res.getResourcePackageName(resId) - + '/' + res.getResourceTypeName(resId) - + '/' + res.getResourceEntryName(resId)); - } - - /** - * Converts a resource into a {@link Bitmap}. If the resource is a vector drawable, it will be - * drawn into a new Bitmap. Otherwise the {@link BitmapFactory} will decode the resource. - */ - @NonNull - public static Bitmap convertToBitmap(Context context, int resourceId) - { - Drawable drawable = context.getDrawable(resourceId); - if (drawable instanceof VectorDrawable) - { - Bitmap bitmap = - Bitmap.createBitmap( - drawable.getIntrinsicWidth(), - drawable.getIntrinsicHeight(), - Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); - drawable.draw(canvas); - return bitmap; - } - return BitmapFactory.decodeResource(context.getResources(), resourceId); - } - - /** - * Leanback launcher requires a uri for poster art so we create a contentUri and - * pass that to LEANBACK_PACKAGE - */ - public static Uri buildBanner(GameFile game, Context context) - { - Uri contentUri = null; - File cover; - - try - { - String customCoverPath = game.getCustomCoverPath(); - - if (ContentHandler.isContentUri(customCoverPath)) - { - try - { - contentUri = ContentHandler.unmangle(customCoverPath); - } - catch (FileNotFoundException | SecurityException ignored) - { - // Let contentUri remain null - } - } - else - { - if ((cover = new File(customCoverPath)).exists()) - { - contentUri = getUriForFile(context, getFileProvider(context), cover); - } - } - - if (contentUri == null) - { - contentUri = Uri.parse(CoverHelper.buildGameTDBUrl(game, CoverHelper.getRegion(game))); - } - - context.grantUriPermission(LEANBACK_PACKAGE, contentUri, FLAG_GRANT_READ_URI_PERMISSION); - } - catch (Exception e) - { - Log.e(TAG, "Failed to create banner"); - Log.e(TAG, e.getMessage()); - } - - return contentUri; - } - - /** - * Needed since debug builds append '.debug' to the end of the package - */ - private static String getFileProvider(Context context) - { - return context.getPackageName() + ".filesprovider"; - } - - /** - * Schedules syncing channels via a {@link JobScheduler}. - * - * @param context for accessing the {@link JobScheduler}. - */ - public static void scheduleSyncingChannel(Context context) - { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) - return; - - ComponentName componentName = new ComponentName(context, SyncChannelJobService.class); - JobInfo.Builder builder = new JobInfo.Builder(1, componentName); - builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY); - - JobScheduler scheduler = - (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); - - Log.d(TAG, "Scheduled channel creation."); - scheduler.schedule(builder.build()); - } - - /** - * Schedulers syncing programs for a channel. The scheduler will listen to a {@link Uri} for a - * particular channel. - * - * @param context for accessing the {@link JobScheduler}. - * @param channelId for the channel to listen for changes. - */ - @TargetApi(Build.VERSION_CODES.O) - public static void scheduleSyncingProgramsForChannel(Context context, long channelId) - { - Log.d(TAG, "ProgramsRefresh job"); - ComponentName componentName = new ComponentName(context, SyncProgramsJobService.class); - JobInfo.Builder builder = - new JobInfo.Builder(getJobIdForChannelId(channelId), componentName); - JobInfo.TriggerContentUri triggerContentUri = - new JobInfo.TriggerContentUri( - TvContractCompat.buildChannelUri(channelId), - JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS); - builder.addTriggerContentUri(triggerContentUri); - builder.setTriggerContentMaxDelay(0L); - builder.setTriggerContentUpdateDelay(0L); - - PersistableBundle bundle = new PersistableBundle(); - bundle.putLong(TvContractCompat.EXTRA_CHANNEL_ID, channelId); - builder.setExtras(bundle); - - JobScheduler scheduler = - (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); - scheduler.cancel(getJobIdForChannelId(channelId)); - scheduler.schedule(builder.build()); - } - - private static int getJobIdForChannelId(long channelId) - { - return (int) (CHANNEL_JOB_ID_OFFSET + channelId); - } - - /** - * Generates all subscriptions for homescreen channels. - */ - public static List createUniversalSubscriptions(Context context) - { - return new ArrayList<>(createPlatformSubscriptions(context)); - } - - private static List createPlatformSubscriptions(Context context) - { - List subs = new ArrayList<>(); - for (PlatformTab platformTab : PlatformTab.values()) - { - subs.add(new HomeScreenChannel( - context.getString(platformTab.getHeaderName()), - context.getString(platformTab.getHeaderName()), - AppLinkHelper.buildBrowseUri(platformTab))); - } - return subs; - } - - public static Boolean isLeanback(Context context) - { - return (context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)); - } -} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/TvUtil.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/TvUtil.kt new file mode 100644 index 0000000000..6af8612822 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/TvUtil.kt @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.utils + +import android.app.job.JobInfo +import android.app.job.JobScheduler +import android.content.ComponentName +import android.content.ContentResolver +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.drawable.VectorDrawable +import android.net.Uri +import android.os.Build +import android.os.PersistableBundle +import android.util.Log +import androidx.annotation.AnyRes +import androidx.annotation.RequiresApi +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.FileProvider.getUriForFile +import androidx.core.graphics.createBitmap +import androidx.core.net.toUri +import androidx.tvprovider.media.tv.Channel +import androidx.tvprovider.media.tv.TvContractCompat +import org.dolphinemu.dolphinemu.model.GameFile +import org.dolphinemu.dolphinemu.model.HomeScreenChannel +import org.dolphinemu.dolphinemu.services.SyncChannelJobService +import org.dolphinemu.dolphinemu.services.SyncProgramsJobService +import org.dolphinemu.dolphinemu.ui.platform.PlatformTab +import java.io.File +import java.io.FileNotFoundException + +/** + * Assists in TV related services, e.g., home screen channels + */ +object TvUtil { + private const val TAG = "TvUtil" + private const val CHANNEL_JOB_ID_OFFSET = 1000L + private const val LEANBACK_PACKAGE = "com.google.android.tvlauncher" + + private val channelsProjection = arrayOf( + TvContractCompat.Channels._ID, + TvContractCompat.Channels.COLUMN_DISPLAY_NAME, + TvContractCompat.Channels.COLUMN_BROWSABLE, + TvContractCompat.Channels.COLUMN_APP_LINK_INTENT_URI + ) + + @JvmStatic + fun getAllChannels(context: Context): List { + val channels = ArrayList() + val cursor = context.contentResolver.query( + TvContractCompat.Channels.CONTENT_URI, channelsProjection, null, null, null + ) + if (cursor != null && cursor.moveToFirst()) { + do { + channels.add(Channel.fromCursor(cursor)) + } while (cursor.moveToNext()) + } + return channels + } + + @JvmStatic + fun getChannelById(context: Context, channelId: Long): Channel? { + for (channel in getAllChannels(context)) { + if (channel.id == channelId) { + return channel + } + } + return null + } + + /** + * Updates all Leanback homescreen channels + */ + @JvmStatic + fun updateAllChannels(context: Context) { + if (Build.VERSION.SDK_INT < 26) { + return + } + + for (channel in getAllChannels(context)) { + context.contentResolver.update( + TvContractCompat.buildChannelUri(channel.id), channel.toContentValues(), null, null + ) + } + } + + @JvmStatic + fun getUriToResource(context: Context, @AnyRes resId: Int): Uri { + val res = context.resources + return (ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + res.getResourcePackageName(resId) + '/' + res.getResourceTypeName( + resId + ) + '/' + res.getResourceEntryName(resId)).toUri() + } + + /** + * Converts a resource into a [Bitmap]. If the resource is a vector drawable, it will be + * drawn into a new Bitmap. Otherwise, the [BitmapFactory] will decode the resource. + */ + @JvmStatic + fun convertToBitmap(context: Context, resourceId: Int): Bitmap { + val drawable = AppCompatResources.getDrawable(context, resourceId) + if (drawable is VectorDrawable) { + val bitmap = createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bitmap + } + return BitmapFactory.decodeResource(context.resources, resourceId) + } + + /** + * Leanback launcher requires a uri for poster art so we create a contentUri and + * pass that to LEANBACK_PACKAGE + */ + @JvmStatic + fun buildBanner(game: GameFile, context: Context): Uri? { + var contentUri: Uri? = null + + try { + val customCoverPath = game.customCoverPath + + if (ContentHandler.isContentUri(customCoverPath)) { + try { + contentUri = ContentHandler.unmangle(customCoverPath) + } catch (_: FileNotFoundException) { + // Let contentUri remain null + } catch (_: SecurityException) { + // Let contentUri remain null + } + } else { + val cover = File(customCoverPath) + if (cover.exists()) { + contentUri = getUriForFile(context, getFileProvider(context), cover) + } + } + + val resolvedContentUri = contentUri ?: (CoverHelper.buildGameTDBUrl( + game, CoverHelper.getRegion(game) + )).toUri() + + context.grantUriPermission( + LEANBACK_PACKAGE, + resolvedContentUri, + android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + contentUri = resolvedContentUri + } catch (e: Exception) { + Log.e(TAG, "Failed to create banner") + Log.e(TAG, e.message ?: "null") + } + + return contentUri + } + + /** + * Needed since debug builds append '.debug' to the end of the package + */ + private fun getFileProvider(context: Context): String { + return context.packageName + ".filesprovider" + } + + /** + * Schedules syncing channels via a [JobScheduler]. + * + * @param context for accessing the [JobScheduler]. + */ + @JvmStatic + fun scheduleSyncingChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + val componentName = ComponentName(context, SyncChannelJobService::class.java) + val builder = JobInfo.Builder(1, componentName) + builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + + val scheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + + Log.d(TAG, "Scheduled channel creation.") + scheduler.schedule(builder.build()) + } + + /** + * Schedulers syncing programs for a channel. The scheduler will listen to a [Uri] for a + * particular channel. + * + * @param context for accessing the [JobScheduler]. + * @param channelId for the channel to listen for changes. + */ + @RequiresApi(Build.VERSION_CODES.O) + @JvmStatic + fun scheduleSyncingProgramsForChannel(context: Context, channelId: Long) { + Log.d(TAG, "ProgramsRefresh job") + val componentName = ComponentName(context, SyncProgramsJobService::class.java) + val builder = JobInfo.Builder(getJobIdForChannelId(channelId), componentName) + val triggerContentUri = JobInfo.TriggerContentUri( + TvContractCompat.buildChannelUri(channelId), + JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS + ) + builder.addTriggerContentUri(triggerContentUri) + builder.setTriggerContentMaxDelay(0L) + builder.setTriggerContentUpdateDelay(0L) + + val bundle = PersistableBundle() + bundle.putLong(TvContractCompat.EXTRA_CHANNEL_ID, channelId) + builder.setExtras(bundle) + + val scheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + scheduler.cancel(getJobIdForChannelId(channelId)) + scheduler.schedule(builder.build()) + } + + private fun getJobIdForChannelId(channelId: Long): Int { + return (CHANNEL_JOB_ID_OFFSET + channelId).toInt() + } + + /** + * Generates all subscriptions for homescreen channels. + */ + @JvmStatic + fun createUniversalSubscriptions(context: Context): List { + return ArrayList(createPlatformSubscriptions(context)) + } + + private fun createPlatformSubscriptions(context: Context): List { + val subscriptions = ArrayList() + for (platformTab in PlatformTab.entries) { + subscriptions.add( + HomeScreenChannel( + context.getString(platformTab.headerName), + context.getString(platformTab.headerName), + AppLinkHelper.buildBrowseUri(platformTab) + ) + ) + } + return subscriptions + } + + @JvmStatic + fun isLeanback(context: Context): Boolean { + return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) + } +}