| /* |
| * Copyright 2018 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| package androidx.tvprovider.media.tv; |
| |
| import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX; |
| |
| import android.annotation.SuppressLint; |
| import android.content.ContentResolver; |
| import android.content.ContentUris; |
| import android.content.Context; |
| import android.database.Cursor; |
| import android.database.sqlite.SQLiteException; |
| import android.graphics.Bitmap; |
| import android.graphics.BitmapFactory; |
| import android.net.Uri; |
| import android.text.format.DateUtils; |
| import android.util.Log; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.RestrictTo; |
| import androidx.annotation.WorkerThread; |
| |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.OutputStream; |
| import java.net.HttpURLConnection; |
| import java.net.URL; |
| import java.net.URLConnection; |
| import java.util.ArrayList; |
| import java.util.List; |
| |
| /** |
| * From a user's perspective, the TV home screen has two types of channels: the single Live |
| * Channels row versus the App preview Channels. This class is concerned with App Channels; or more |
| * precisely: <i>your</i> app's preview Channels. In API 26+, all TV apps are allowed to create |
| * multiple channels and publish those Channels to the home screen. |
| * <p> |
| * This class provides convenience methods to help you publish, update and delete channels; add, |
| * update or remove programs in a channel. You do not need to know anything about Content |
| * Providers, Content Resolvers, Cursors or such to publish your channels. This class abstracts |
| * away all database interactions for you. |
| * <p> |
| * To make it easy for you to distinguish classes that help you build App Channels, the support |
| * library uses the prefix Preview- to denote the classes that pertain to app Channels. Hence, |
| * the classes {@link PreviewChannel} and {@link PreviewProgram} help your app add channels to the |
| * TV home page. |
| * |
| * All calls to methods in the class should be made on worker threads. |
| */ |
| |
| @WorkerThread |
| public class PreviewChannelHelper { |
| |
| private static final String TAG = "PreviewChannelHelper"; |
| private static final int DEFAULT_URL_CONNNECTION_TIMEOUT_MILLIS = |
| (int) (3 * DateUtils.SECOND_IN_MILLIS); |
| private static final int DEFAULT_READ_TIMEOUT_MILLIS = (int) (10 * DateUtils.SECOND_IN_MILLIS); |
| private static final int INVALID_CONTENT_ID = -1; |
| private final int mUrlConnectionTimeoutMillis; |
| private final int mUrlReadTimeoutMillis; |
| private final Context mContext; |
| |
| public PreviewChannelHelper(Context context) { |
| this(context, DEFAULT_URL_CONNNECTION_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS); |
| } |
| |
| /** |
| * @param urlConnectionTimeoutMillis see {@link URLConnection#setConnectTimeout(int)} |
| * @param urlReadTimeoutMillis see {@link URLConnection#setReadTimeout(int)} |
| */ |
| public PreviewChannelHelper(Context context, int urlConnectionTimeoutMillis, |
| int urlReadTimeoutMillis) { |
| mContext = context; |
| mUrlConnectionTimeoutMillis = urlConnectionTimeoutMillis; |
| mUrlReadTimeoutMillis = urlReadTimeoutMillis; |
| } |
| |
| /** |
| * Publishing a channel to the TV home screen is a two step process: first, you add the |
| * channel to the TV content provider; second, you make the channel browsable (i.e. visible). |
| * {@link #publishChannel(PreviewChannel) This method} adds the channel to the |
| * TV content provider for you and returns a channelId. Next you must use the channelId |
| * to make the channel browsable. |
| * </br> |
| * There are two ways you can make a channel browsable: |
| * </br> |
| * a) For your first channel, simply ask the system to make the channel browsable: |
| * TvContractCompat.requestChannelBrowsable(context,channelId) |
| * </br> |
| * b) For any additional channel beyond the first channel, you must get permission |
| * from the user. So if this channel is not your first channel, you must request user |
| * permission through the following intent. So take the channelId returned by |
| * {@link #publishChannel(PreviewChannel) this method} and do the following |
| * inside an Activity or Fragment: |
| * </br> |
| * <pre> |
| * intent = new Intent(TvContractCompat.ACTION_REQUEST_CHANNEL_BROWSABLE); |
| * intent.putExtra(TvContractCompat.EXTRA_CHANNEL_ID, channelId); |
| * startActivityForResult(intent, REQUEST_CHANNEL_BROWSABLE); |
| * </pre> |
| * |
| * <p> |
| * Creating a PreviewChannel, you may pass to the builder a |
| * {@link PreviewChannel.Builder#setLogo(Uri) url as your logo}. In such case, |
| * {@link #updatePreviewChannel(long, PreviewChannel)} will load the logo over the network. To |
| * use your own networking code, override {@link #downloadBitmap(Uri)}. |
| * |
| * @return channelId or -1 if insertion fails. This is the id the system assigns to your |
| * published channel. You can use it later to get a reference to this published PreviewChannel. |
| */ |
| public long publishChannel(@NonNull PreviewChannel channel) throws IOException { |
| try { |
| Uri channelUri = mContext.getContentResolver().insert( |
| TvContractCompat.Channels.CONTENT_URI, |
| channel.toContentValues()); |
| if (null == channelUri || channelUri.equals(Uri.EMPTY)) { |
| throw new NullPointerException("Channel insertion failed"); |
| } |
| long channelId = ContentUris.parseId(channelUri); |
| boolean logoAdded = addChannelLogo(channelId, channel); |
| // Rollback channel insertion if logo could not be added. |
| if (!logoAdded) { |
| deletePreviewChannel(channelId); |
| throw new IOException("Failed to add logo, so channel (ID=" |
| + channelId + ") was not created"); |
| } |
| return channelId; |
| } catch (SecurityException e) { |
| Log.e(TAG, "Your app's ability to insert data into the TvProvider" |
| + " may have been revoked.", e); |
| } |
| return INVALID_CONTENT_ID; |
| } |
| |
| /** |
| * This is a convenience method that simply publishes your first channel for you. After calling |
| * {@link #publishChannel(PreviewChannel)} to add the channel to the TvProvider, it |
| * calls {@link TvContractCompat#requestChannelBrowsable(Context, long)} to make the channel |
| * visible. |
| * <p> |
| * Only use this method to publish your first channel as you do not need user permission to |
| * make your first channel browsable (i.e. visible on home screen). For additional channels, |
| * see the documentations for {@link #publishChannel(PreviewChannel)}. |
| * |
| * <p> |
| * Creating a PreviewChannel, you may pass to the builder a |
| * {@link PreviewChannel.Builder#setLogo(Uri) url as your logo}. In such case, |
| * {@link #updatePreviewChannel(long, PreviewChannel)} will load the logo over the network. To |
| * use your own networking code, override {@link #downloadBitmap(Uri)}. |
| * |
| * @return channelId: This is the id the system assigns to your published channel. You can |
| * use it later to get a reference to this published PreviewChannel. |
| */ |
| public long publishDefaultChannel(@NonNull PreviewChannel channel) |
| throws IOException { |
| long channelId = publishChannel(channel); |
| TvContractCompat.requestChannelBrowsable(mContext, channelId); |
| return channelId; |
| } |
| |
| /** |
| * The TvProvider does not allow select queries. Hence, unless you are querying for a |
| * {@link #getPreviewChannel(long) single PreviewChannel by id}, you must get all of |
| * your channels at once and then use the returned list as necessary. |
| */ |
| public List<PreviewChannel> getAllChannels() { |
| Cursor cursor = mContext.getContentResolver() |
| .query( |
| TvContractCompat.Channels.CONTENT_URI, |
| PreviewChannel.Columns.PROJECTION, |
| null, |
| null, |
| null); |
| |
| List<PreviewChannel> channels = new ArrayList<>(); |
| if (cursor != null && cursor.moveToFirst()) { |
| do { |
| channels.add(PreviewChannel.fromCursor(cursor)); |
| } while (cursor.moveToNext()); |
| } |
| return channels; |
| } |
| |
| /** |
| * Retrieves a single preview channel from the TvProvider. When you publish a preview channel, |
| * the TvProvider assigns an ID to it. That's the channelId to use here. |
| * |
| * @param channelId ID of preview channel in TvProvider |
| * @return PreviewChannel or null if not found |
| */ |
| public PreviewChannel getPreviewChannel(long channelId) { |
| PreviewChannel channel = null; |
| Uri channelUri = TvContractCompat.buildChannelUri(channelId); |
| Cursor cursor = mContext.getContentResolver() |
| .query(channelUri, PreviewChannel.Columns.PROJECTION, null, null, null); |
| if (cursor != null && cursor.moveToFirst()) { |
| channel = PreviewChannel.fromCursor(cursor); |
| } |
| return channel; |
| } |
| |
| /** |
| * To update a preview channel, you need to use the {@link PreviewChannel.Builder} to set the |
| * attributes you wish to change. Then simply pass in the built channel and the channelId of the |
| * preview channel. (The channelId is the ID you received when you originally |
| * {@link #publishChannel(PreviewChannel) published} the preview channel.) |
| * <p> |
| * Creating a PreviewChannel, you may pass to the builder a |
| * {@link PreviewChannel.Builder#setLogo(Uri) url as your logo}. In such case, |
| * {@link #updatePreviewChannel(long, PreviewChannel)} will load the logo over the network. To |
| * use your own networking code, override {@link #downloadBitmap(Uri)}. |
| */ |
| public void updatePreviewChannel(long channelId, |
| @NonNull PreviewChannel update) throws IOException { |
| // To avoid possibly expensive no-op updates, first check that the current content that's |
| // in the database is different from the new content to be added. |
| PreviewChannel curr = getPreviewChannel(channelId); |
| if (curr != null && curr.hasAnyUpdatedValues(update)) { |
| updatePreviewChannelInternal(channelId, update); |
| } |
| if (update.isLogoChanged()) { |
| boolean logoAdded = addChannelLogo(channelId, update); |
| if (!logoAdded) { |
| throw new IOException("Fail to update channel (ID=" + channelId + ") logo."); |
| } |
| } |
| } |
| |
| /** |
| * Inner methods that does the actual work of updating a Preview Channel. The method is |
| * extracted to make {@link #updatePreviewChannel(long, PreviewChannel)} testable. |
| * |
| * @hide |
| */ |
| @RestrictTo(LIBRARY_GROUP_PREFIX) |
| protected void updatePreviewChannelInternal(long channelId, @NonNull PreviewChannel upgrade) { |
| mContext.getContentResolver().update( |
| TvContractCompat.buildChannelUri(channelId), |
| upgrade.toContentValues(), |
| null, |
| null); |
| } |
| |
| /** |
| * Internally, a logo is added to a channel after the channel has been added to the TvProvider. |
| * This private method is called by one of the publish methods, to add a logo to the TvProvider |
| * and associate the logo to the given channel identified by channelId. Because each channel |
| * must have a logo, a NullPointerException is thrown if the channel being published has no |
| * associated logo to publish with it. |
| */ |
| @WorkerThread |
| @SuppressLint("WrongThread") // TODO https://issuetracker.google.com/issues/116776070 |
| private boolean addChannelLogo(long channelId, @NonNull PreviewChannel channel) { |
| boolean result = false; |
| if (!channel.isLogoChanged()) { |
| return result; |
| } |
| Bitmap logo = channel.getLogo(mContext); |
| if (logo == null) { |
| logo = getLogoFromUri(channel.getLogoUri()); |
| } |
| Uri logoUri = TvContractCompat.buildChannelLogoUri(channelId); |
| try (OutputStream outputStream = mContext.getContentResolver().openOutputStream( |
| logoUri)) { |
| result = logo.compress(Bitmap.CompressFormat.PNG, 100, outputStream); |
| outputStream.flush(); |
| } catch (SQLiteException | IOException | NullPointerException e) { |
| Log.i(TAG, "Failed to add logo to the published channel (ID= " + channelId + ")", e); |
| } |
| return result; |
| } |
| |
| /** |
| * Handles the case where the Bitmap must be fetched from a known uri. First the |
| * method checks if the Uri is local. If not, the method makes a connection to fetch the Bitmap |
| * data from its remote location. To use your own networking implementation, simply override |
| * {@link #downloadBitmap(Uri)} |
| */ |
| private Bitmap getLogoFromUri(@NonNull Uri logoUri) { |
| String scheme = logoUri.normalizeScheme().getScheme(); |
| InputStream inputStream = null; |
| Bitmap logoImage = null; |
| |
| try { |
| if (ContentResolver.SCHEME_ANDROID_RESOURCE.equals(scheme) |
| || ContentResolver.SCHEME_FILE.equals(scheme) |
| || ContentResolver.SCHEME_CONTENT.equals(scheme)) { |
| // for local resource |
| inputStream = mContext.getContentResolver().openInputStream(logoUri); |
| logoImage = BitmapFactory.decodeStream(inputStream); |
| } else { |
| logoImage = downloadBitmap(logoUri); |
| } |
| |
| } catch (IOException e) { |
| Log.e(TAG, "Failed to get logo from the URI: " + logoUri, e); |
| } finally { |
| if (inputStream != null) { |
| try { |
| inputStream.close(); |
| } catch (IOException e) { |
| // Do nothing |
| } |
| } |
| } |
| return logoImage; |
| } |
| |
| /** |
| * Downloads a Bitmap from a remote server. It is declared protected to allow you |
| * to override it to use your own networking implementation if you so wish. |
| */ |
| protected Bitmap downloadBitmap(@NonNull Uri logoUri) throws IOException { |
| URLConnection urlConnection = null; |
| InputStream inputStream = null; |
| Bitmap logoImage = null; |
| try { |
| // for remote resource |
| urlConnection = new URL(logoUri.toString()).openConnection(); |
| urlConnection.setConnectTimeout(mUrlConnectionTimeoutMillis); |
| urlConnection.setReadTimeout(mUrlReadTimeoutMillis); |
| inputStream = urlConnection.getInputStream(); |
| logoImage = BitmapFactory.decodeStream(inputStream); |
| } finally { |
| if (inputStream != null) { |
| try { |
| inputStream.close(); |
| } catch (IOException e) { |
| // Do nothing |
| } |
| } |
| if (urlConnection instanceof HttpURLConnection) { |
| ((HttpURLConnection) urlConnection).disconnect(); |
| } |
| } |
| return logoImage; |
| } |
| |
| /** |
| * Removes a preview channel from the system's content provider (aka TvProvider). |
| */ |
| public void deletePreviewChannel(long channelId) { |
| mContext.getContentResolver().delete( |
| TvContractCompat.buildChannelUri(channelId), |
| null, |
| null); |
| } |
| |
| /** |
| * Adds programs to a preview channel. |
| */ |
| public long publishPreviewProgram(@NonNull PreviewProgram program) { |
| try { |
| Uri programUri = mContext.getContentResolver().insert( |
| TvContractCompat.PreviewPrograms.CONTENT_URI, |
| program.toContentValues()); |
| long programId = ContentUris.parseId(programUri); |
| return programId; |
| } catch (SecurityException e) { |
| Log.e(TAG, "Your app's ability to insert data into the TvProvider" |
| + " may have been revoked.", e); |
| } |
| return INVALID_CONTENT_ID; |
| } |
| |
| /** |
| * Retrieves a single preview program from the system content provider (aka TvProvider). |
| */ |
| public PreviewProgram getPreviewProgram(long programId) { |
| PreviewProgram program = null; |
| Uri programUri = TvContractCompat.buildPreviewProgramUri(programId); |
| Cursor cursor = mContext.getContentResolver().query(programUri, null, null, null, null); |
| if (cursor != null && cursor.moveToFirst()) { |
| program = PreviewProgram.fromCursor(cursor); |
| } |
| return program; |
| } |
| |
| /** |
| * Updates programs in a preview channel. |
| */ |
| public void updatePreviewProgram(long programId, @NonNull PreviewProgram update) { |
| // To avoid possibly expensive no-op updates, first check that the current content that's |
| // in the database is different from the new content to be added. |
| PreviewProgram curr = getPreviewProgram(programId); |
| if (curr != null && curr.hasAnyUpdatedValues(update)) { |
| updatePreviewProgramInternal(programId, update); |
| } |
| } |
| |
| /** |
| * Inner methods that does the actual work of updating a Preview Program. The method is |
| * extracted to make {@link #updatePreviewProgram(long, PreviewProgram)} testable. |
| * |
| * @hide |
| */ |
| @RestrictTo(LIBRARY_GROUP_PREFIX) |
| void updatePreviewProgramInternal(long programId, @NonNull PreviewProgram upgrade) { |
| mContext.getContentResolver().update( |
| TvContractCompat.buildPreviewProgramUri(programId), |
| upgrade.toContentValues(), null, null); |
| } |
| |
| /** |
| * Removes programs from a preview channel. |
| */ |
| public void deletePreviewProgram(long programId) { |
| mContext.getContentResolver().delete( |
| TvContractCompat.buildPreviewProgramUri(programId), null, null); |
| } |
| |
| /** |
| * Adds a program to the Watch Next channel |
| */ |
| public long publishWatchNextProgram(@NonNull WatchNextProgram program) { |
| try { |
| Uri programUri = mContext.getContentResolver().insert( |
| TvContractCompat.WatchNextPrograms.CONTENT_URI, program.toContentValues()); |
| return ContentUris.parseId(programUri); |
| } catch (SecurityException e) { |
| Log.e(TAG, "Your app's ability to insert data into the TvProvider" |
| + " may have been revoked.", e); |
| } |
| return INVALID_CONTENT_ID; |
| } |
| |
| /** |
| * Retrieves a single WatchNext program from the system content provider (aka TvProvider). |
| */ |
| public WatchNextProgram getWatchNextProgram(long programId) { |
| WatchNextProgram program = null; |
| Uri programUri = TvContractCompat.buildWatchNextProgramUri(programId); |
| Cursor cursor = mContext.getContentResolver().query(programUri, null, null, null, null); |
| if (cursor != null && cursor.moveToFirst()) { |
| program = WatchNextProgram.fromCursor(cursor); |
| } |
| return program; |
| } |
| |
| /** |
| * Updates a WatchNext program. |
| */ |
| public void updateWatchNextProgram(@NonNull WatchNextProgram upgrade, long programId) { |
| // To avoid possibly expensive no-op updates, first check that the current content that's in |
| // the database is different from the new content to be added. |
| WatchNextProgram curr = getWatchNextProgram(programId); |
| if (curr != null && curr.hasAnyUpdatedValues(upgrade)) { |
| updateWatchNextProgram(programId, upgrade); |
| } |
| } |
| |
| /** |
| * Inner methods that does the actual work of updating a Watch Next Program. The method is |
| * extracted to make {@link #updateWatchNextProgram(WatchNextProgram, long)} testable. |
| * |
| * @hide |
| */ |
| @RestrictTo(LIBRARY_GROUP_PREFIX) |
| void updateWatchNextProgram(long programId, @NonNull WatchNextProgram upgrade) { |
| mContext.getContentResolver().update( |
| TvContractCompat.buildWatchNextProgramUri(programId), |
| upgrade.toContentValues(), null, null); |
| } |
| } |