Merge pull request #14629 from Simonx22/android/convert-tvutil-to-kotlin

Android: Convert TvUtil to Kotlin
This commit is contained in:
Dentomologist 2026-05-07 13:23:48 -07:00 committed by GitHub
commit 30a20d75d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 247 additions and 296 deletions

View File

@ -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<Channel> getAllChannels(Context context)
{
List<Channel> 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<HomeScreenChannel> createUniversalSubscriptions(Context context)
{
return new ArrayList<>(createPlatformSubscriptions(context));
}
private static List<HomeScreenChannel> createPlatformSubscriptions(Context context)
{
List<HomeScreenChannel> 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));
}
}

View File

@ -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<Channel> {
val channels = ArrayList<Channel>()
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<HomeScreenChannel> {
return ArrayList(createPlatformSubscriptions(context))
}
private fun createPlatformSubscriptions(context: Context): List<HomeScreenChannel> {
val subscriptions = ArrayList<HomeScreenChannel>()
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)
}
}