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