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