diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d6d92e24..7797144d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,8 @@
+
+
-
+
+
= 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);
}
diff --git a/app/src/main/java/com/limelight/PcView.java b/app/src/main/java/com/limelight/PcView.java
index 7e4ffeb9..45e51f62 100644
--- a/app/src/main/java/com/limelight/PcView.java
+++ b/app/src/main/java/com/limelight/PcView.java
@@ -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();
diff --git a/app/src/main/java/com/limelight/PosterContentProvider.java b/app/src/main/java/com/limelight/PosterContentProvider.java
new file mode 100644
index 00000000..0b456084
--- /dev/null
+++ b/app/src/main/java/com/limelight/PosterContentProvider.java
@@ -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 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();
+ }
+
+}
diff --git a/app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java b/app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java
index 1a26e59e..aebd5e74 100644
--- a/app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java
+++ b/app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java
@@ -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;
diff --git a/app/src/main/java/com/limelight/utils/TvChannelHelper.java b/app/src/main/java/com/limelight/utils/TvChannelHelper.java
new file mode 100644
index 00000000..82daa2de
--- /dev/null
+++ b/app/src/main/java/com/limelight/utils/TvChannelHelper.java
@@ -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 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);
+ }
+
+ }
+}
diff --git a/app/src/main/res/drawable/ic_channel.xml b/app/src/main/res/drawable/ic_channel.xml
new file mode 100644
index 00000000..4dada9a7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_channel.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_lime_layer.xml b/app/src/main/res/drawable/ic_lime_layer.xml
new file mode 100644
index 00000000..74ce061a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_lime_layer.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 6a4eda53..77d32ca9 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -66,6 +66,7 @@
Выйти из сессии
Выйти из текущей игры и запустить
Отмена
+ Добавать на канал
Список приложений
Обновление приложений…
Ошибка
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 55c1e590..55e6779e 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -3,5 +3,7 @@
16dp
16dp
+ 80dp
+ 80dp
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7e2eb098..2eda9513 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -96,6 +96,7 @@
Cancel
View Details
Create Shortcut
+ Add to Channel
App List
Refreshing apps…
Error