Android: Support for AndroidTV Oreo Homescreen channels

This commit is contained in:
zackhow 2018-07-08 14:29:03 -04:00
parent 14317c8a0f
commit 0488fe1d30
12 changed files with 1034 additions and 2 deletions

View File

@ -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'

View File

@ -16,6 +16,8 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="com.android.providers.tv.permission.READ_EPG_DATA" />
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<application
android:name=".DolphinApplication"
@ -69,9 +71,27 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity android:name=".activities.AppLinkActivity" >
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:host="@string/host"
android:scheme="@string/scheme" />
</intent-filter>
</activity>
<service android:name=".services.DirectoryInitializationService"/>
<service android:name=".services.GameFileCacheService"/>
<service
android:name=".services.SyncChannelJobService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<service
android:name=".services.SyncProgramsJobService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<provider
android:name="android.support.v4.content.FileProvider"

View File

@ -0,0 +1,134 @@
package org.dolphinemu.dolphinemu.activities;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.Uri;
import android.os.Bundle;
import android.support.v4.app.FragmentActivity;
import android.support.v4.content.LocalBroadcastManager;
import android.util.Log;
import android.widget.Toast;
import org.dolphinemu.dolphinemu.R;
import org.dolphinemu.dolphinemu.model.GameFile;
import org.dolphinemu.dolphinemu.services.DirectoryInitializationService;
import org.dolphinemu.dolphinemu.services.GameFileCacheService;
import org.dolphinemu.dolphinemu.ui.main.TvMainActivity;
import org.dolphinemu.dolphinemu.utils.AppLinkHelper;
import org.dolphinemu.dolphinemu.utils.DirectoryStateReceiver;
/**
* Linker between leanback homescreen and app
*/
public class AppLinkActivity extends FragmentActivity
{
private static final String TAG = "AppLinkActivity";
private AppLinkHelper.PlayAction playAction;
private DirectoryStateReceiver directoryStateReceiver;
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
Intent intent = getIntent();
Uri uri = intent.getData();
Log.v(TAG, uri.toString());
if (uri.getPathSegments().isEmpty())
{
Log.e(TAG, "Invalid uri " + uri);
finish();
return;
}
AppLinkHelper.AppLinkAction action = AppLinkHelper.extractAction(uri);
switch (action.getAction())
{
case AppLinkHelper.PLAY:
playAction = (AppLinkHelper.PlayAction) action;
initResources();
break;
case AppLinkHelper.BROWSE:
browse();
break;
default:
throw new IllegalArgumentException("Invalid Action " + action);
}
}
/**
* Need to init these since they usually occur in the main activity.
*/
private void initResources()
{
IntentFilter statusIntentFilter = new IntentFilter(
DirectoryInitializationService.BROADCAST_ACTION);
directoryStateReceiver =
new DirectoryStateReceiver(directoryInitializationState ->
{
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);
}
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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<Void, Void, Boolean>
{
private Context context;
SyncChannelTask(Context context)
{
this.context = context;
}
/**
* Setup channels
*/
@TargetApi(Build.VERSION_CODES.O)
@Override
protected Boolean doInBackground(Void... voids)
{
List<HomeScreenChannel> subscriptions;
List<Channel> channels = TvUtil.getAllChannels(context);
List<Long> 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;
}
}
}

View File

@ -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<Long, Void, Boolean>
{
private Context context;
private List<GameFile> 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<Long> 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();
}
}
}

View File

@ -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();
}

View File

@ -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<String> 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;
}
}
}

View File

@ -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<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();
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<HomeScreenChannel> createUniversalSubscriptions()
{
//Leaving the subs local variable in case more channels are created other than platforms.
List<HomeScreenChannel> subs = new ArrayList<>(createPlatformSubscriptions());
return subs;
}
private static List<HomeScreenChannel> createPlatformSubscriptions()
{
List<HomeScreenChannel> subs = new ArrayList<>();
for (Platform platform : Platform.values())
{
subs.add(new HomeScreenChannel(
platform.getHeaderName(),
platform.getHeaderName(),
AppLinkHelper.buildBrowseUri(platform.getHeaderName()).toString()));
}
return subs;
}
}

View File

@ -3,6 +3,8 @@
<!-- Title of the app -->
<string name="app_name">Dolphin Emulator</string>
<string name="host">app</string>
<string name="scheme">dolphinemu</string>
<!-- WARNING Do not move these controller entries AT ALL COSTS! They are indexed with ints, and an assumption
is made that they are placed together so that we can access them sequentially in a loop. -->
@ -281,4 +283,7 @@
<string name="emulation_change_disc">Change Disc</string>
<string name="external_storage_not_mounted">The external storage needs to be available in order to use Dolphin</string>
<!-- Extra Leanback Homescreen Channels-->
<string name="homescreen_favorites">Favorites</string>
</resources>