Merge branch 'tv-channels' of https://github.com/GinVavilon/moonlight-android into GinVavilon-tv-channels

This commit is contained in:
Cameron Gutman
2019-07-11 19:19:15 -07:00
11 changed files with 481 additions and 3 deletions

View File

@@ -19,6 +19,7 @@ import com.limelight.utils.Dialog;
import com.limelight.utils.ServerHelper;
import com.limelight.utils.ShortcutHelper;
import com.limelight.utils.SpinnerDialog;
import com.limelight.utils.TvChannelHelper;
import com.limelight.utils.UiHelper;
import android.app.Activity;
@@ -50,6 +51,7 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
private AppGridAdapter appGridAdapter;
private String uuidString;
private ShortcutHelper shortcutHelper;
private TvChannelHelper tvChannelHelper;
private ComputerDetails computer;
private ComputerManagerService.ApplistPoller poller;
@@ -65,6 +67,7 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
private final static int START_WITH_QUIT = 4;
private final static int VIEW_DETAILS_ID = 5;
private final static int CREATE_SHORTCUT_ID = 6;
private final static int ADD_TO_TV_CHANNEL = 7;
public final static String NAME_EXTRA = "Name";
public final static String UUID_EXTRA = "UUID";
@@ -184,6 +187,9 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
shortcutHelper.disableShortcut(details.uuid,
getResources().getString(R.string.scut_not_paired));
//Delete channel of PC for now
tvChannelHelper.deleteChannel(details.uuid);
// Display a toast to the user and quit the activity
Toast.makeText(AppView.this, getResources().getText(R.string.scut_not_paired), Toast.LENGTH_SHORT).show();
finish();
@@ -252,6 +258,7 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
inForeground = true;
shortcutHelper = new ShortcutHelper(this);
tvChannelHelper = new TvChannelHelper(this);
UiHelper.setLocale(this);
@@ -270,6 +277,7 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
// Add a launcher shortcut for this PC (forced, since this is user interaction)
shortcutHelper.createAppViewShortcut(uuidString, computerName, uuidString, true);
shortcutHelper.reportShortcutUsed(uuidString);
tvChannelHelper.createTvChannel(uuidString, computerName);
// Bind to the computer manager service
bindService(new Intent(this, ComputerManagerService.class), serviceConnection,
@@ -348,7 +356,9 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
}
menu.add(Menu.NONE, VIEW_DETAILS_ID, 3, getResources().getString(R.string.applist_menu_details));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (tvChannelHelper.isSupport()) {
menu.add(Menu.NONE, ADD_TO_TV_CHANNEL, 4, getResources().getString(R.string.applist_menu_tv_channel));
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Only add an option to create shortcut if box art is loaded
// and when we're in grid-mode (not list-mode).
ImageView appImageView = info.targetView.findViewById(R.id.grid_image);
@@ -424,6 +434,10 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
}
return true;
case ADD_TO_TV_CHANNEL:
tvChannelHelper.addGameToChannel(computer, app.app);
return true;
default:
return super.onContextItemSelected(item);
}

View File

@@ -25,6 +25,7 @@ import com.limelight.utils.Dialog;
import com.limelight.utils.HelpLauncher;
import com.limelight.utils.ServerHelper;
import com.limelight.utils.ShortcutHelper;
import com.limelight.utils.TvChannelHelper;
import com.limelight.utils.UiHelper;
import android.app.Activity;
@@ -62,6 +63,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
private RelativeLayout noPcFoundLayout;
private PcGridAdapter pcGridAdapter;
private ShortcutHelper shortcutHelper;
private TvChannelHelper tvChannelHelper;
private ComputerManagerService.ComputerManagerBinder managerBinder;
private boolean freezeUpdates, runningPolling, inForeground, completeOnCreateCalled;
private final ServiceConnection serviceConnection = new ServiceConnection() {
@@ -215,6 +217,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
completeOnCreateCalled = true;
shortcutHelper = new ShortcutHelper(this);
tvChannelHelper = new TvChannelHelper(this);
UiHelper.setLocale(this);
@@ -637,6 +640,9 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
shortcutHelper.disableShortcut(details.uuid,
getResources().getString(R.string.scut_deleted_pc));
//Delete channel of PC
tvChannelHelper.deleteChannel(details.uuid);
pcGridAdapter.removeComputer(computer);
pcGridAdapter.notifyDataSetChanged();

View File

@@ -0,0 +1,107 @@
package com.limelight;
import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import com.limelight.grid.assets.DiskAssetLoader;
import java.io.File;
import java.io.FileNotFoundException;
import java.util.List;
public class PosterContentProvider extends ContentProvider {
public static final String AUTHORITY = "poster." + BuildConfig.APPLICATION_ID;
public static final String PNG_MIME_TYPE = "image/png";
public static final int APP_ID_PATH_INDEX = 2;
public static final int COMPUTER_UUID_PATH_INDEX = 1;
private DiskAssetLoader mDiskAssetLoader;
private static final UriMatcher sUriMatcher;
private static final String BOXART_PATH = "boxart";
private static final int BOXART_URI_ID = 1;
static {
sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
sUriMatcher.addURI(AUTHORITY, BOXART_PATH, BOXART_URI_ID);
}
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
int match = sUriMatcher.match(uri);
if (match == BOXART_URI_ID) {
return openBoxArtFile(uri, mode);
}
return openBoxArtFile(uri, mode);
}
public ParcelFileDescriptor openBoxArtFile(Uri uri, String mode) throws FileNotFoundException {
if (!"r".equals(mode)) {
throw new UnsupportedOperationException("This provider is only for read mode");
}
List<String> segments = uri.getPathSegments();
if (segments.size() != 3) {
throw new FileNotFoundException();
}
String appId = segments.get(APP_ID_PATH_INDEX);
String uuid = segments.get(COMPUTER_UUID_PATH_INDEX);
File file = mDiskAssetLoader.getFile(uuid, Integer.parseInt(appId));
if (file.exists()) {
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
}
throw new FileNotFoundException();
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("This provider is only for read mode");
}
@Override
public String getType(Uri uri) {
return PNG_MIME_TYPE;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
throw new UnsupportedOperationException("This provider is only for read mode");
}
@Override
public boolean onCreate() {
mDiskAssetLoader = new DiskAssetLoader(getContext());
return true;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
throw new UnsupportedOperationException("This provider doesn't support query");
}
@Override
public int update(Uri uri, ContentValues values, String selection,
String[] selectionArgs) {
throw new UnsupportedOperationException("This provider is support read only");
}
public static Uri createBoxArtUri(String uuid, String appId) {
return new Uri.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority(AUTHORITY)
.appendPath(BOXART_PATH)
.appendPath(uuid)
.appendPath(appId)
.build();
}
}

View File

@@ -65,7 +65,7 @@ public class DiskAssetLoader {
}
public Bitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple, int sampleSize) {
File file = CacheHelper.openPath(false, cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png");
File file = getFile(tuple.computer.uuid, tuple.app.getAppId());
// Don't bother with anything if it doesn't exist
if (!file.exists()) {
@@ -133,6 +133,10 @@ public class DiskAssetLoader {
return bmp;
}
public File getFile(String computerUuid, int appId) {
return CacheHelper.openPath(false, cacheDir, "boxart", computerUuid, appId + ".png");
}
public void populateCacheWithStream(CachedAppAssetLoader.LoaderTuple tuple, InputStream input) {
OutputStream out = null;
boolean success = false;

View File

@@ -0,0 +1,307 @@
package com.limelight.utils;
import android.annotation.TargetApi;
import android.app.UiModeManager;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.database.Cursor;
import android.database.sqlite.SQLiteException;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
import android.media.tv.TvContract;
import android.net.Uri;
import android.os.Build;
import com.limelight.AppView;
import com.limelight.LimeLog;
import com.limelight.PosterContentProvider;
import com.limelight.R;
import com.limelight.ShortcutTrampoline;
import com.limelight.nvstream.http.ComputerDetails;
import com.limelight.nvstream.http.NvApp;
import java.io.IOException;
import java.io.OutputStream;
@TargetApi(Build.VERSION_CODES.O)
public class TvChannelHelper {
private static final int ASPECT_RATIO_MOVIE_POSTER = 5;
private static final int TYPE_GAME = 12;
public static final String[] CHANNEL_PROJECTION = {TvContract.Channels._ID, TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID};
public static final int INTERNAL_PROVIDER_ID_INDEX = 1;
public static final int ID_INDEX = 0;
private Context context;
public TvChannelHelper(Context context) {
this.context = context;
}
public boolean createTvChannel(String computerUuid, String computerName) {
if (!isSupport()) {
return false;
}
Intent i = new Intent(context, ShortcutTrampoline.class);
i.putExtra(AppView.NAME_EXTRA, computerName);
i.putExtra(AppView.UUID_EXTRA, computerUuid);
i.setAction(Intent.ACTION_DEFAULT);
ChannelBuilder builder = new ChannelBuilder()
.setType(TvContract.Channels.TYPE_PREVIEW)
.setDisplayName(computerName)
.setInternalProviderId(computerUuid)
.setAppLinkIntent(i);
Long channelId = getChannelId(computerUuid);
if (channelId != null) {
context.getContentResolver().update(TvContract.buildChannelUri(channelId),
builder.toContentValues(), null, null);
return false;
}
Uri channelUri = context.getContentResolver().insert(
TvContract.Channels.CONTENT_URI, builder.toContentValues());
if (channelUri != null) {
long id = ContentUris.parseId(channelUri);
updateChannelIcon(id);
return true;
}
return false;
}
private void updateChannelIcon(long id) {
Bitmap bitmap = drawableToBitmap(context.getResources().getDrawable(R.drawable.ic_channel));
try {
storeChannelLogo(id, bitmap);
} finally {
bitmap.recycle();
}
}
/**
* Stores the given channel logo {@link Bitmap} in the system content provider and associate
* it with the given channel ID.
*
* @param channelId the ID of the target channel with which the given logo should be associated
* @param logo the logo image to be stored
* @return {@code true} if successfully stored the logo in the system content provider,
* otherwise {@code false}.
*/
private boolean storeChannelLogo(long channelId,
Bitmap logo) {
if (!isSupport()) {
return false;
}
boolean result = false;
Uri localUri = TvContract.buildChannelLogoUri(channelId);
try (OutputStream outputStream = context.getContentResolver().openOutputStream(localUri)) {
result = logo.compress(Bitmap.CompressFormat.PNG, 100, outputStream);
outputStream.flush();
} catch (SQLiteException | IOException e) {
LimeLog.warning("Failed to store the logo to the system content provider.");
e.printStackTrace();
}
return result;
}
private Bitmap drawableToBitmap(Drawable drawable) {
int width = context.getResources().getDimensionPixelSize(R.dimen.tv_channel_logo_width);
int height = context.getResources().getDimensionPixelSize(R.dimen.tv_channel_logo_width);
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
drawable.draw(canvas);
return bitmap;
}
public boolean addGameToChannel(String computerUuid, String computerName, String appId, String appName) {
if (!isSupport()) {
return false;
}
PreviewProgramBuilder builder = new PreviewProgramBuilder();
Intent i = new Intent(context, ShortcutTrampoline.class);
i.putExtra(AppView.NAME_EXTRA, computerName);
i.putExtra(AppView.UUID_EXTRA, computerUuid);
i.putExtra(ShortcutTrampoline.APP_ID_EXTRA, appId);
i.setAction(Intent.ACTION_DEFAULT);
Uri resourceURI = PosterContentProvider.createBoxArtUri(computerUuid, appId);
Long channelId = getChannelId(computerUuid);
if (channelId == null) {
return false;
}
builder.setChannelId(channelId)
.setType(TYPE_GAME)
.setTitle(appName)
.setPosterArtAspectRatio(ASPECT_RATIO_MOVIE_POSTER)
.setPosterArtUri(resourceURI)
.setIntent(i)
.setInternalProviderId(appId);
Uri programUri = context.getContentResolver().insert(TvContract.PreviewPrograms.CONTENT_URI,
builder.toContentValues());
TvContract.requestChannelBrowsable(context, channelId);
return programUri != null;
}
public boolean deleteChannel(String computerUuid) {
if (!isSupport()) {
return false;
}
Long channelId = getChannelId(computerUuid);
if (channelId == null) {
return false;
}
Uri uri = TvContract.buildChannelUri(channelId);
return context.getContentResolver().delete(uri, null, null) > 0;
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
private Long getChannelId(String computerUuid) {
Uri uri = TvContract.Channels.CONTENT_URI;
Cursor cursor = context.getContentResolver().query(uri,
CHANNEL_PROJECTION,
null,
null,
null);
try {
if (cursor == null) {
return null;
}
while (cursor.moveToNext()) {
String internalProviderId = cursor.getString(INTERNAL_PROVIDER_ID_INDEX);
if (computerUuid.equals(internalProviderId)) {
return cursor.getLong(ID_INDEX);
}
}
return null;
} finally {
if (cursor != null) {
cursor.close();
}
}
}
public void addGameToChannel(ComputerDetails computer, NvApp app) {
addGameToChannel(computer.uuid, computer.name, String.valueOf(app.getAppId()), app.getAppName());
}
private static <T> String toValueString(T value) {
return value == null ? null : value.toString();
}
private static String toUriString(Intent intent) {
return intent == null ? null : intent.toUri(Intent.URI_INTENT_SCHEME);
}
public boolean isSupport() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return false;
}
UiModeManager uiModeManager = (UiModeManager) context.getSystemService(Context.UI_MODE_SERVICE);
return uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
}
private static class PreviewProgramBuilder {
private ContentValues mValues = new ContentValues();
public PreviewProgramBuilder setChannelId(Long channelId) {
mValues.put(TvContract.PreviewPrograms.COLUMN_CHANNEL_ID, channelId);
return this;
}
public PreviewProgramBuilder setType(int type) {
mValues.put(TvContract.PreviewPrograms.COLUMN_TYPE, type);
return this;
}
public PreviewProgramBuilder setTitle(String title) {
mValues.put(TvContract.PreviewPrograms.COLUMN_TITLE, title);
return this;
}
public PreviewProgramBuilder setPosterArtAspectRatio(int aspectRatio) {
mValues.put(TvContract.PreviewPrograms.COLUMN_POSTER_ART_ASPECT_RATIO, aspectRatio);
return this;
}
public PreviewProgramBuilder setIntent(Intent intent) {
mValues.put(TvContract.PreviewPrograms.COLUMN_INTENT_URI, toUriString(intent));
return this;
}
public PreviewProgramBuilder setIntentUri(Uri uri) {
mValues.put(TvContract.PreviewPrograms.COLUMN_INTENT_URI, toValueString(uri));
return this;
}
public PreviewProgramBuilder setInternalProviderId(String id) {
mValues.put(TvContract.PreviewPrograms.COLUMN_INTERNAL_PROVIDER_ID, id);
return this;
}
public PreviewProgramBuilder setPosterArtUri(Uri uri) {
mValues.put(TvContract.PreviewPrograms.COLUMN_POSTER_ART_URI, toValueString(uri));
return this;
}
public ContentValues toContentValues() {
return new ContentValues(mValues);
}
}
private static class ChannelBuilder {
private ContentValues mValues = new ContentValues();
public ChannelBuilder setType(String type) {
mValues.put(TvContract.Channels.COLUMN_TYPE, type);
return this;
}
public ChannelBuilder setDisplayName(String displayName) {
mValues.put(TvContract.Channels.COLUMN_DISPLAY_NAME, displayName);
return this;
}
public ChannelBuilder setInternalProviderId(String internalProviderId) {
mValues.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID, internalProviderId);
return this;
}
public ChannelBuilder setAppLinkIntent(Intent intent) {
mValues.put(TvContract.Channels.COLUMN_APP_LINK_INTENT_URI, toUriString(intent));
return this;
}
public ContentValues toContentValues() {
return new ContentValues(mValues);
}
}
}