From 0488fe1d30b0645a7f7af63b5c0d0090adf738a6 Mon Sep 17 00:00:00 2001 From: zackhow Date: Sun, 8 Jul 2018 14:29:03 -0400 Subject: [PATCH] Android: Support for AndroidTV Oreo Homescreen channels --- Source/Android/app/build.gradle | 1 + .../Android/app/src/main/AndroidManifest.xml | 20 ++ .../activities/AppLinkActivity.java | 134 ++++++++ .../activities/EmulationActivity.java | 10 +- .../dolphinemu/model/HomeScreenChannel.java | 64 ++++ .../services/GameFileCacheService.java | 13 + .../services/SyncChannelJobService.java | 166 ++++++++++ .../services/SyncProgramsJobService.java | 161 ++++++++++ .../dolphinemu/ui/main/TvMainActivity.java | 7 + .../dolphinemu/utils/AppLinkHelper.java | 159 ++++++++++ .../dolphinemu/dolphinemu/utils/TvUtil.java | 296 ++++++++++++++++++ .../app/src/main/res/values/strings.xml | 5 + 12 files changed, 1034 insertions(+), 2 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/AppLinkActivity.java create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/HomeScreenChannel.java create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/SyncChannelJobService.java create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/SyncProgramsJobService.java create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/AppLinkHelper.java create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/TvUtil.java diff --git a/Source/Android/app/build.gradle b/Source/Android/app/build.gradle index 06e6f41c10..c2228a2aaf 100644 --- a/Source/Android/app/build.gradle +++ b/Source/Android/app/build.gradle @@ -86,6 +86,7 @@ dependencies { // Android TV UI libraries. implementation "com.android.support:leanback-v17:$androidSupportVersion" + implementation "com.android.support:support-tv-provider:$androidSupportVersion" // For showing the banner as a circle a-la Material Design Guidelines implementation 'de.hdodenhof:circleimageview:2.1.0' diff --git a/Source/Android/app/src/main/AndroidManifest.xml b/Source/Android/app/src/main/AndroidManifest.xml index 03562e7447..b31ddcfb5d 100644 --- a/Source/Android/app/src/main/AndroidManifest.xml +++ b/Source/Android/app/src/main/AndroidManifest.xml @@ -16,6 +16,8 @@ + + + + + + + + + + + + + { + if (directoryInitializationState == DirectoryInitializationService.DirectoryInitializationState.DOLPHIN_DIRECTORIES_INITIALIZED) + { + play(playAction); + } + else if (directoryInitializationState == DirectoryInitializationService.DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) + { + Toast.makeText(this, R.string.write_permission_needed, Toast.LENGTH_SHORT) + .show(); + } + else if (directoryInitializationState == DirectoryInitializationService.DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) + { + Toast.makeText(this, R.string.external_storage_not_mounted, Toast.LENGTH_SHORT) + .show(); + } + }); + + // Registers the DirectoryStateReceiver and its intent filters + LocalBroadcastManager.getInstance(this).registerReceiver( + directoryStateReceiver, + statusIntentFilter); + DirectoryInitializationService.startService(this); + GameFileCacheService.startLoad(this); + } + + /** + * Action if channel icon is selected + */ + private void browse() + { + Intent openApp = new Intent(this, TvMainActivity.class); + startActivity(openApp); + + finish(); + } + + /** + * Action if program(game) is selected + */ + private void play(AppLinkHelper.PlayAction action) + { + Log.d(TAG, "Playing game " + + action.getGameId() + + " from channel " + + action.getChannelId()); + + GameFile game = GameFileCacheService.getGameFileByGameId(action.getGameId()); + if (game == null) + Log.e(TAG, "Invalid Game: " + action.getGameId()); + else + startGame(game); + finish(); + } + + private void startGame(GameFile game) + { + if (directoryStateReceiver != null) + { + LocalBroadcastManager.getInstance(this).unregisterReceiver(directoryStateReceiver); + directoryStateReceiver = null; + } + EmulationActivity.launch(this, game, -1, null); + } +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.java index 4a02cfc89c..de3e0f0fc1 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.java @@ -158,15 +158,21 @@ public final class EmulationActivity extends AppCompatActivity launcher.putExtra(EXTRA_PLATFORM, gameFile.getPlatform()); launcher.putExtra(EXTRA_SCREEN_PATH, gameFile.getScreenshotPath()); launcher.putExtra(EXTRA_GRID_POSITION, position); + Bundle options = new Bundle(); - ActivityOptionsCompat options = ActivityOptionsCompat.makeSceneTransitionAnimation( + // Will be null if launched from homescreen + if (sharedView != null) + { + ActivityOptionsCompat transition = ActivityOptionsCompat.makeSceneTransitionAnimation( activity, sharedView, "image_game_screenshot"); + options = transition.toBundle(); + } // I believe this warning is a bug. Activities are FragmentActivity from the support lib //noinspection RestrictedApi - activity.startActivityForResult(launcher, MainPresenter.REQUEST_EMULATE_GAME, options.toBundle()); + activity.startActivityForResult(launcher, MainPresenter.REQUEST_EMULATE_GAME, options); } @Override diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/HomeScreenChannel.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/HomeScreenChannel.java new file mode 100644 index 0000000000..73b536397d --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/model/HomeScreenChannel.java @@ -0,0 +1,64 @@ +package org.dolphinemu.dolphinemu.model; + +/** + * Represents a home screen channel for Android TV api 26+ + */ +public class HomeScreenChannel +{ + + private long channelId; + private String name; + private String description; + private String appLinkIntentUri; + + public HomeScreenChannel() + { + } + + public HomeScreenChannel(String name, String description, String appLinkIntentUri) + { + this.name = name; + this.description = description; + this.appLinkIntentUri = appLinkIntentUri; + } + + public long getChannelId() + { + return channelId; + } + + public void setChannelId(long channelId) + { + this.channelId = channelId; + } + + public String getName() + { + return name; + } + + public void setName(String name) + { + this.name = name; + } + + public String getDescription() + { + return description; + } + + public void setDescription(String description) + { + this.description = description; + } + + public String getAppLinkIntentUri() + { + return appLinkIntentUri; + } + + public void setAppLinkIntentUri(String appLinkIntentUri) + { + this.appLinkIntentUri = appLinkIntentUri; + } +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/GameFileCacheService.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/GameFileCacheService.java index a43d939f4d..8333246f62 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/GameFileCacheService.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/GameFileCacheService.java @@ -50,6 +50,19 @@ public final class GameFileCacheService extends IntentService return platformGames; } + public static GameFile getGameFileByGameId(String gameId) + { + GameFile[] allGames = gameFiles.get(); + for (GameFile game : allGames) + { + if (game.getGameId().equals(gameId)) + { + return game; + } + } + return null; + } + private static void startService(Context context, String action) { Intent intent = new Intent(context, GameFileCacheService.class); diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/SyncChannelJobService.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/SyncChannelJobService.java new file mode 100644 index 0000000000..20f5731513 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/SyncChannelJobService.java @@ -0,0 +1,166 @@ +package org.dolphinemu.dolphinemu.services; + +import android.annotation.TargetApi; +import android.app.job.JobParameters; +import android.app.job.JobService; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.media.tv.TvContract; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.support.media.tv.Channel; +import android.support.media.tv.ChannelLogoUtils; +import android.support.media.tv.TvContractCompat; +import android.util.Log; + +import org.dolphinemu.dolphinemu.R; +import org.dolphinemu.dolphinemu.model.HomeScreenChannel; +import org.dolphinemu.dolphinemu.utils.TvUtil; + +import java.util.ArrayList; +import java.util.List; + +public class SyncChannelJobService extends JobService +{ + private static final String TAG = "ChannelJobSvc"; + + private SyncChannelTask mSyncChannelTask; + + @Override + public boolean onStartJob(final JobParameters jobParameters) + { + Log.d(TAG, "Starting channel creation job"); + mSyncChannelTask = + new SyncChannelTask(getApplicationContext()) + { + @Override + protected void onPostExecute(Boolean success) + { + super.onPostExecute(success); + jobFinished(jobParameters, !success); + } + }; + mSyncChannelTask.execute(); + return true; + } + + @Override + public boolean onStopJob(JobParameters jobParameters) + { + if (mSyncChannelTask != null) + { + mSyncChannelTask.cancel(true); + } + return true; + } + + private static class SyncChannelTask extends AsyncTask + { + private Context context; + + SyncChannelTask(Context context) + { + this.context = context; + } + + /** + * Setup channels + */ + @TargetApi(Build.VERSION_CODES.O) + @Override + protected Boolean doInBackground(Void... voids) + { + List subscriptions; + List channels = TvUtil.getAllChannels(context); + List channelIds = new ArrayList<>(); + // Checks if the default channels are added. + // If not, create the channels + if (!channels.isEmpty()) + { + channels.forEach(channel -> channelIds.add(channel.getId())); + } + else + { + subscriptions = TvUtil.createUniversalSubscriptions(); + for (HomeScreenChannel subscription : subscriptions) + { + long channelId = createChannel(subscription); + channelIds.add(channelId); + subscription.setChannelId(channelId); + // Only the first channel added can be browsable without user intervention. + TvContractCompat.requestChannelBrowsable(context, channelId); + } + } + // Schedule triggers to update programs + channelIds.forEach(channel -> TvUtil.scheduleSyncingProgramsForChannel(context, channel)); + // Update all channels + TvUtil.updateAllChannels(context); + return true; + } + + private long createChannel(HomeScreenChannel subscription) + { + long channelId = getChannelIdFromTvProvider(context, subscription); + if (channelId != -1L) + { + return channelId; + } + + // Create the channel since it has not been added to the TV Provider. + Uri appLinkIntentUri = Uri.parse(subscription.getAppLinkIntentUri()); + + Channel.Builder builder = new Channel.Builder(); + builder.setType(TvContractCompat.Channels.TYPE_PREVIEW) + .setDisplayName(subscription.getName()) + .setDescription(subscription.getDescription()) + .setAppLinkIntentUri(appLinkIntentUri); + + Log.d(TAG, "Creating channel: " + subscription.getName()); + Uri channelUrl = + context.getContentResolver() + .insert( + TvContractCompat.Channels.CONTENT_URI, + builder.build().toContentValues()); + + channelId = ContentUris.parseId(channelUrl); + Bitmap bitmap = TvUtil.convertToBitmap(context, R.drawable.ic_launcher); + ChannelLogoUtils.storeChannelLogo(context, channelId, bitmap); + + return channelId; + } + + private long getChannelIdFromTvProvider(Context context, HomeScreenChannel subscription) + { + Cursor cursor = + context.getContentResolver().query( + TvContractCompat.Channels.CONTENT_URI, + new String[]{ + TvContractCompat.Channels._ID, + TvContract.Channels.COLUMN_DISPLAY_NAME + }, + null, + null, + null); + if (cursor != null && cursor.moveToFirst()) + { + do + { + Channel channel = Channel.fromCursor(cursor); + if (subscription.getName().equals(channel.getDisplayName())) + { + Log.d( + TAG, + "Channel already exists. Returning channel " + + channel.getId() + + " from TV Provider."); + return channel.getId(); + } + } while (cursor.moveToNext()); + } + return -1L; + } + } +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/SyncProgramsJobService.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/SyncProgramsJobService.java new file mode 100644 index 0000000000..63330653b5 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/services/SyncProgramsJobService.java @@ -0,0 +1,161 @@ +package org.dolphinemu.dolphinemu.services; + +import android.app.job.JobParameters; +import android.app.job.JobService; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.PersistableBundle; +import android.support.media.tv.Channel; +import android.support.media.tv.PreviewProgram; +import android.support.media.tv.TvContractCompat; +import android.util.Log; + +import org.dolphinemu.dolphinemu.R; +import org.dolphinemu.dolphinemu.model.GameFile; +import org.dolphinemu.dolphinemu.ui.platform.Platform; +import org.dolphinemu.dolphinemu.utils.AppLinkHelper; +import org.dolphinemu.dolphinemu.utils.TvUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class SyncProgramsJobService extends JobService +{ + private static final String TAG = "SyncProgramsJobService"; + + private SyncProgramsTask mSyncProgramsTask; + + @Override + public boolean onStartJob(final JobParameters jobParameters) + { + Log.d(TAG, "onStartJob(): " + jobParameters); + final long channelId = getChannelId(jobParameters); + if (channelId == -1L) + { + Log.d(TAG, "Failed to find channel"); + return false; + } + + mSyncProgramsTask = + new SyncProgramsTask(getApplicationContext()) + { + @Override + protected void onPostExecute(Boolean finished) + { + super.onPostExecute(finished); + mSyncProgramsTask = null; + jobFinished(jobParameters, !finished); + } + }; + mSyncProgramsTask.execute(channelId); + return true; + } + + @Override + public boolean onStopJob(JobParameters jobParameters) + { + if (mSyncProgramsTask != null) + { + mSyncProgramsTask.cancel(true); + } + return true; + } + + private long getChannelId(JobParameters jobParameters) + { + PersistableBundle extras = jobParameters.getExtras(); + return extras.getLong(TvContractCompat.EXTRA_CHANNEL_ID, -1L); + } + + private static class SyncProgramsTask extends AsyncTask + { + private Context context; + private List updatePrograms; + + private SyncProgramsTask(Context context) + { + this.context = context; + updatePrograms = new ArrayList<>(); + } + + /** + * Determines which channel to update, get the game files for the channel, + * then updates the list + */ + @Override + protected Boolean doInBackground(Long... channelIds) + { + List params = Arrays.asList(channelIds); + if (!params.isEmpty()) + { + for (Long channelId : params) + { + Channel channel = TvUtil.getChannelById(context, channelId); + for (Platform platform : Platform.values()) + { + if (channel != null && channel.getDisplayName().equals(platform.getHeaderName())) + { + getGamesByPlatform(platform); + syncPrograms(channelId); + } + } + } + } + return true; + } + + private void getGamesByPlatform(Platform platform) + { + updatePrograms = GameFileCacheService.getGameFilesForPlatform(platform); + } + + private void syncPrograms(long channelId) + { + Log.d(TAG, "Sync programs for channel: " + channelId); + deletePrograms(channelId); + createPrograms(channelId); + } + + private void createPrograms(long channelId) + { + for (GameFile game : updatePrograms) + { + PreviewProgram previewProgram = buildProgram(channelId, game); + + context.getContentResolver() + .insert( + TvContractCompat.PreviewPrograms.CONTENT_URI, + previewProgram.toContentValues()); + } + } + + private void deletePrograms(long channelId) + { + context.getContentResolver().delete( + TvContractCompat.buildPreviewProgramsUriForChannel(channelId), + null, + null); + } + + private PreviewProgram buildProgram(long channelId, GameFile game) + { + Uri appLinkUri = AppLinkHelper.buildGameUri(channelId, game.getGameId()); + Uri banner = TvUtil.buildBanner(game, context); + if (banner == null) + banner = TvUtil.getUriToResource(context, R.drawable.banner_tv); + + PreviewProgram.Builder builder = new PreviewProgram.Builder(); + builder.setChannelId(channelId) + .setType(TvContractCompat.PreviewProgramColumns.TYPE_GAME) + .setTitle(game.getTitle()) + .setDescription(game.getDescription()) + .setPosterArtUri(banner) + .setIntentUri(appLinkUri); + return builder.build(); + } + } +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/TvMainActivity.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/TvMainActivity.java index 27b5ce6711..4ab556f647 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/TvMainActivity.java +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/TvMainActivity.java @@ -2,6 +2,7 @@ package org.dolphinemu.dolphinemu.ui.main; import android.content.Intent; import android.content.pm.PackageManager; +import android.os.Build; import android.os.Bundle; import android.support.v17.leanback.app.BrowseFragment; import android.support.v17.leanback.app.BrowseSupportFragment; @@ -28,6 +29,7 @@ import org.dolphinemu.dolphinemu.ui.settings.SettingsActivity; import org.dolphinemu.dolphinemu.utils.FileBrowserHelper; import org.dolphinemu.dolphinemu.utils.PermissionsHandler; import org.dolphinemu.dolphinemu.utils.StartupHandler; +import org.dolphinemu.dolphinemu.utils.TvUtil; import org.dolphinemu.dolphinemu.viewholders.TvGameViewHolder; import java.util.Collection; @@ -53,6 +55,8 @@ public final class TvMainActivity extends FragmentActivity implements MainView // Stuff in this block only happens when this activity is newly created (i.e. not a rotation) if (savedInstanceState == null) StartupHandler.HandleInit(this); + // Setup and/or sync channels + TvUtil.scheduleSyncingChannel(getApplicationContext()); } @Override @@ -134,6 +138,9 @@ public final class TvMainActivity extends FragmentActivity implements MainView @Override public void showGames() { + // Kicks off the program services to update all channels + TvUtil.updateAllChannels(getApplicationContext()); + recreate(); } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/AppLinkHelper.java b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/AppLinkHelper.java new file mode 100644 index 0000000000..dbc1dcba98 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/AppLinkHelper.java @@ -0,0 +1,159 @@ +package org.dolphinemu.dolphinemu.utils; + +import android.net.Uri; +import android.support.annotation.StringDef; + +import java.util.List; + +/** + * Helps link home screen selection to a game. + */ +public class AppLinkHelper +{ + public static final String PLAY = "play"; + public static final String BROWSE = "browse"; + private static final String SCHEMA_URI_PREFIX = "dolphinemu://app/"; + private static final String URI_PLAY = SCHEMA_URI_PREFIX + PLAY; + private static final String URI_VIEW = SCHEMA_URI_PREFIX + BROWSE; + private static final int URI_INDEX_OPTION = 0; + private static final int URI_INDEX_CHANNEL = 1; + private static final int URI_INDEX_GAME = 2; + + public static Uri buildGameUri(long channelId, String gameId) + { + return Uri.parse(URI_PLAY) + .buildUpon() + .appendPath(String.valueOf(channelId)) + .appendPath(String.valueOf(gameId)) + .build(); + } + + public static Uri buildBrowseUri(String subscriptionName) + { + return Uri.parse(URI_VIEW).buildUpon().appendPath(subscriptionName).build(); + } + + public static AppLinkAction extractAction(Uri uri) + { + if (isGameUri(uri)) + return new PlayAction(extractChannelId(uri), extractGameId(uri)); + else if (isBrowseUri(uri)) + return new BrowseAction(extractSubscriptionName(uri)); + + throw new IllegalArgumentException("No action found for uri " + uri); + } + + private static boolean isGameUri(Uri uri) + { + if (uri.getPathSegments().isEmpty()) + { + return false; + } + String option = uri.getPathSegments().get(URI_INDEX_OPTION); + return PLAY.equals(option); + } + + private static boolean isBrowseUri(Uri uri) + { + if (uri.getPathSegments().isEmpty()) + return false; + + String option = uri.getPathSegments().get(URI_INDEX_OPTION); + return BROWSE.equals(option); + } + + private static String extractSubscriptionName(Uri uri) + { + return extract(uri, URI_INDEX_CHANNEL); + } + + private static long extractChannelId(Uri uri) + { + return extractLong(uri, URI_INDEX_CHANNEL); + } + + private static String extractGameId(Uri uri) + { + return extract(uri, URI_INDEX_GAME); + } + + private static long extractLong(Uri uri, int index) + { + return Long.valueOf(extract(uri, index)); + } + + private static String extract(Uri uri, int index) + { + List pathSegments = uri.getPathSegments(); + if (pathSegments.isEmpty() || pathSegments.size() < index) + return null; + return pathSegments.get(index); + } + + @StringDef({BROWSE, PLAY}) + public @interface ActionFlags + { + } + + /** + * Action for deep linking. + */ + public interface AppLinkAction + { + /** + * Returns an string representation of the action. + */ + @ActionFlags + String getAction(); + } + + /** + * Action when clicking the channel icon + */ + public static class BrowseAction implements AppLinkAction + { + private final String mSubscriptionName; + + private BrowseAction(String subscriptionName) + { + this.mSubscriptionName = subscriptionName; + } + + @Override + public String getAction() + { + return BROWSE; + } + } + + /** + * Action when clicking a program(game) + */ + public static class PlayAction implements AppLinkAction + { + private final long channelId; + private final String gameId; + + private PlayAction(long channelId, String gameId) + { + this.channelId = channelId; + this.gameId = gameId; + } + + public long getChannelId() + { + return channelId; + } + + public String getGameId() + { + return gameId; + } + + @Override + public String getAction() + { + return PLAY; + } + } +} 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 new file mode 100644 index 0000000000..d13cace229 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/TvUtil.java @@ -0,0 +1,296 @@ +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.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.media.tv.TvContract; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.PersistableBundle; +import android.support.annotation.AnyRes; +import android.support.annotation.NonNull; +import android.support.media.tv.Channel; +import android.support.media.tv.TvContractCompat; +import android.util.Log; + +import org.dolphinemu.dolphinemu.R; +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.Platform; + +import java.io.File; +import java.io.FileOutputStream; +import java.util.ArrayList; +import java.util.List; + +import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION; +import static android.support.v4.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, + TvContract.Channels.COLUMN_DISPLAY_NAME, + TvContractCompat.Channels.COLUMN_BROWSABLE + }; + 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(); + Uri resUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + + "://" + res.getResourcePackageName(resId) + + '/' + res.getResourceTypeName(resId) + + '/' + res.getResourceEntryName(resId)); + return resUri; + } + + /** + * 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 lanucher requires a uri for poster art, so we take the banner vector, + * make a bitmap, save that bitmap, then return the file provider uri. + */ + public static Uri buildBanner(GameFile game, Context context) + { + Uri contentUri = null; + + try + { + //Substring needed to strip "file:" from the path beginning + File screenshotFile = new File(game.getScreenshotPath().substring(5)); + if (screenshotFile.exists()) + { + contentUri = getUriForFile(context, getFilePrivider(context), screenshotFile); + } + else + { + File file = new File(buildBannerFilename(game.getGameId())); + if (!file.exists()) + { + int[] vector = game.getBanner(); + int width = game.getBannerWidth(); + int height = game.getBannerHeight(); + + if (vector.length > 0 || width > 0 || height > 0) + { + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + bitmap.setPixels(vector, 0, width, 0, 0, width, height); + FileOutputStream out = new FileOutputStream(file); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); + out.close(); + } + else + return null; + } + contentUri = getUriForFile(context, getFilePrivider(context), file); + } + 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; + } + + private static String buildBannerFilename(String gameId) + { + return Environment.getExternalStorageDirectory().getPath() + + "/dolphin-emu/Cache/" + gameId + "_banner.png"; + } + + /** + * Needed since debug builds append '.debug' to the end of the package + */ + private static String getFilePrivider(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) + { + 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() + { + //Leaving the subs local variable in case more channels are created other than platforms. + List subs = new ArrayList<>(createPlatformSubscriptions()); + return subs; + } + + private static List createPlatformSubscriptions() + { + List subs = new ArrayList<>(); + for (Platform platform : Platform.values()) + { + subs.add(new HomeScreenChannel( + platform.getHeaderName(), + platform.getHeaderName(), + AppLinkHelper.buildBrowseUri(platform.getHeaderName()).toString())); + } + return subs; + } +} diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 6d1341edf4..79f1378c1c 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -3,6 +3,8 @@ Dolphin Emulator + app + dolphinemu @@ -281,4 +283,7 @@ Change Disc The external storage needs to be available in order to use Dolphin + + + Favorites