diff --git a/app/build.gradle b/app/build.gradle index bed0377..ea5eeaa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -8,8 +8,8 @@ android { applicationId "news.androidtv.tvapprepo" minSdkVersion 21 targetSdkVersion 25 - versionCode 11 - versionName "1.0.7-b" + versionCode 12 + versionName "1.0.7-c" } buildTypes { release { @@ -60,6 +60,7 @@ dependencies { compile 'com.google.firebase:firebase-database:10.0.1' compile 'com.google.android.gms:play-services-ads:10.0.1' compile 'com.google.firebase:firebase-config:10.0.1' + compile 'com.google.firebase:firebase-ads:10.0.1' // compile 'com.colortv:android-sdk:2.1.0' compile 'com.github.bumptech.glide:glide:3.7.0' diff --git a/app/src/androidTest/java/news/androidtv/tvapprepo/ApplicationTest.java b/app/src/androidTest/java/news/androidtv/tvapprepo/ApplicationTest.java index ef3c7a1..35db6e0 100644 --- a/app/src/androidTest/java/news/androidtv/tvapprepo/ApplicationTest.java +++ b/app/src/androidTest/java/news/androidtv/tvapprepo/ApplicationTest.java @@ -1,13 +1,52 @@ package news.androidtv.tvapprepo; import android.app.Application; +import android.content.ComponentName; +import android.content.Intent; import android.test.ApplicationTestCase; +import android.util.Log; + +import java.io.File; +import java.net.URISyntaxException; + +import dalvik.annotation.TestTargetClass; +import news.androidtv.tvapprepo.intents.IntentUriGenerator; /** * Testing Fundamentals */ public class ApplicationTest extends ApplicationTestCase { + public static final String TAG = ApplicationTest.class.getSimpleName(); + public ApplicationTest() { super(Application.class); } + + public void testWebBookmarks() { + final String expected = "intent://google.com#Intent;scheme=http;end"; + String actual = IntentUriGenerator.generateWebBookmark("http://google.com"); + Log.d(TAG, actual); + assertEquals(expected, actual); + } + + public void testActivityShortcut() { + final String expected = "intent:#Intent;component=news.androidtv.tvapprepo/.activities.SettingsActivity;end"; + String actual = IntentUriGenerator.generateActivityShortcut(new ComponentName("news.androidtv.tvapprepo", ".activities.SettingsActivity")); + Log.d(TAG, actual); + assertEquals(expected, actual); + } + + public void testFileOpening() { + // Note: This can be flaky if your device doesn't have this file. Future versions of this + // test should create and delete a temporary file. + final String expected = "intent:///storage/emulated/0/Download/com.felkertech.n.cumulustv.test.apk#Intent;scheme=file;launchFlags=0x10000000;end"; + String actual = IntentUriGenerator.generateVideoPlayback(new File("/storage/emulated/0/Download/com.felkertech.n.cumulustv.test.apk")); + Log.d(TAG, actual); + assertEquals(expected, actual); + } + + public void testOpenGoogle() throws URISyntaxException { + String string = "intent:#Intent;component=news.androidtv.tvapprepo/.activities.SettingsActivity;end"; + getContext().startActivity(Intent.parseUri(string, Intent.URI_INTENT_SCHEME)); + } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 397ea01..46d25f7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -55,6 +55,15 @@ + + + + + + diff --git a/app/src/main/java/news/androidtv/tvapprepo/activities/AdvancedShortcutActivity.java b/app/src/main/java/news/androidtv/tvapprepo/activities/AdvancedShortcutActivity.java new file mode 100644 index 0000000..9b87316 --- /dev/null +++ b/app/src/main/java/news/androidtv/tvapprepo/activities/AdvancedShortcutActivity.java @@ -0,0 +1,129 @@ +package news.androidtv.tvapprepo.activities; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v7.app.AlertDialog; +import android.util.Log; +import android.view.ContextThemeWrapper; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.Switch; +import android.widget.Toast; + +import news.androidtv.tvapprepo.R; +import news.androidtv.tvapprepo.iconography.IconsTask; +import news.androidtv.tvapprepo.iconography.PackedIcon; +import news.androidtv.tvapprepo.model.AdvancedOptions; +import news.androidtv.tvapprepo.utils.GenerateShortcutHelper; + +/** + * Created by Nick on 4/24/2017. + * + * Dialogs are not a very good user interface if they start nesting. Instead, we will use a pull-out + * panel that comes in from the right and shows a variety of settings. This will scale a lot better + * as we can have more real-estate. + */ +public class AdvancedShortcutActivity extends Activity { + private static final String TAG = AdvancedShortcutActivity.class.getSimpleName(); + + public static final String EXTRA_RESOLVE_INFO = "resolveInfo"; + public static final String EXTRA_ADVANCED_OPTIONS = "advancedOptions"; + + private AdvancedOptions advancedOptions; + private ResolveInfo resolveInfo; + private IconsTask.IconsReceivedCallback callback = new IconsTask.IconsReceivedCallback() { + @Override + public void onIcons(PackedIcon[] icons) { + Log.d(TAG, icons.length + "<<<"); + // Show all icons for the user to select (or let them do their own) + LinearLayout iconDialogLayout = (LinearLayout) findViewById(R.id.icon_list); + iconDialogLayout.removeAllViews(); + for (final PackedIcon icon : icons) { + ImageButton imageButton = new ImageButton(AdvancedShortcutActivity.this); + imageButton.setImageDrawable(icon.icon); + imageButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (icon.isBanner) { + advancedOptions.setBannerBitmap(icon.getBitmap()); + } else { + advancedOptions.setIconBitmap(icon.getBitmap()); + } + Log.d(TAG, advancedOptions.toString()); + } + }); + iconDialogLayout.addView(imageButton); + } + } + }; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (getActionBar() != null) { + getActionBar().hide(); + } + setContentView(R.layout.activity_advanced); + + if (getIntent().hasExtra(EXTRA_RESOLVE_INFO)) { + resolveInfo = getIntent().getParcelableExtra(EXTRA_RESOLVE_INFO); + } + if (getIntent().hasExtra(EXTRA_ADVANCED_OPTIONS)) { + advancedOptions = getIntent().getParcelableExtra(EXTRA_ADVANCED_OPTIONS); + } + + if (advancedOptions == null) { + advancedOptions = new AdvancedOptions(this); + } + + loadCustomIconography(); + + findViewById(R.id.generate).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + publish(); + finish(); + } + }); + + // Turn into side-panel + // Sets the size and position of dialog activity. + WindowManager.LayoutParams layoutParams = getWindow().getAttributes(); + layoutParams.gravity = Gravity.END | Gravity.CENTER_VERTICAL; + layoutParams.width = getResources().getDimensionPixelSize(R.dimen.side_panel_width); + layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT; + getWindow().setAttributes(layoutParams); + } + + private void publish() { + boolean isGame = ((Switch) findViewById(R.id.switch_isgame)).isChecked(); + String bannerUrl = + ((EditText) findViewById(R.id.edit_banner)).getText().toString(); + if (!bannerUrl.isEmpty()) { + advancedOptions.setBannerUrl(bannerUrl); + } + advancedOptions.setIsGame(isGame); + GenerateShortcutHelper.generateShortcut(this, resolveInfo, advancedOptions); + } + + private void loadCustomIconography() { + if (resolveInfo != null) { + IconsTask.getIconsForComponentName(this, + new ComponentName(resolveInfo.activityInfo.packageName, + resolveInfo.activityInfo.name), callback); + + } else { + Toast.makeText(this, "Cannot set banner of non-app yet", Toast.LENGTH_SHORT).show(); + } + } +} diff --git a/app/src/main/java/news/androidtv/tvapprepo/fragments/MainFragment.java b/app/src/main/java/news/androidtv/tvapprepo/fragments/MainFragment.java index 11e9038..582e05d 100644 --- a/app/src/main/java/news/androidtv/tvapprepo/fragments/MainFragment.java +++ b/app/src/main/java/news/androidtv/tvapprepo/fragments/MainFragment.java @@ -1,17 +1,3 @@ -/* - * Copyright (C) 2014 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 news.androidtv.tvapprepo.fragments; import android.app.Activity; @@ -24,7 +10,6 @@ import android.os.Environment; import android.os.Handler; import android.os.Looper; -import android.preference.PreferenceActivity; import android.support.annotation.NonNull; import android.support.v17.leanback.app.BackgroundManager; import android.support.v17.leanback.app.BrowseFragment; @@ -40,24 +25,19 @@ import android.support.v17.leanback.widget.RowPresenter; import android.support.v4.app.ActivityOptionsCompat; import android.support.v7.app.AlertDialog; -import android.support.v7.view.ContextThemeWrapper; import android.util.DisplayMetrics; import android.util.Log; +import android.view.ContextThemeWrapper; import android.widget.EditText; import android.widget.Toast; import com.afollestad.materialdialogs.DialogAction; import com.afollestad.materialdialogs.MaterialDialog; -import com.android.volley.NetworkResponse; -import com.android.volley.VolleyError; import com.bumptech.glide.Glide; import com.bumptech.glide.load.resource.drawable.GlideDrawable; import com.bumptech.glide.request.animation.GlideAnimation; import com.bumptech.glide.request.target.SimpleTarget; -import org.json.JSONException; -import org.json.JSONObject; - import java.io.File; import java.net.URI; import java.util.ArrayList; @@ -82,20 +62,15 @@ import news.androidtv.tvapprepo.presenters.DownloadedFilesPresenter; import news.androidtv.tvapprepo.presenters.LauncherActivitiesPresenter; import news.androidtv.tvapprepo.presenters.OptionsCardPresenter; +import news.androidtv.tvapprepo.ui.ShortcutGeneratorDialogs; import news.androidtv.tvapprepo.utils.GenerateShortcutHelper; import news.androidtv.tvapprepo.utils.PackageInstallerUtils; -import news.androidtv.tvapprepo.utils.ShortcutPostTask; import tv.puppetmaster.tinydl.PackageInstaller; public class MainFragment extends BrowseFragment { private static final String TAG = MainFragment.class.getSimpleName(); - private static final boolean DEBUG_SHOW_APKS = true; private static final int BACKGROUND_UPDATE_DELAY = 300; - private static final int GRID_ITEM_WIDTH = 200; - private static final int GRID_ITEM_HEIGHT = 200; - private static final int NUM_ROWS = 6; - private static final int NUM_COLS = 15; private boolean checkedForUpdates = true; private Activity mMainActivity; @@ -174,7 +149,6 @@ public void onDestroy() { @Override public void onStart() { super.onStart(); -// loadRows(); } @Override @@ -217,6 +191,8 @@ private void loadRows() { createRowShortcutGenerator(); + createRowCustomShortcuts(); + createRowMisc(); setAdapter(mRowsAdapter); @@ -324,6 +300,126 @@ private void createRowShortcutGenerator() { mRowsAdapter.add(new ListRow(launcherActivitiesHeader, launcherActivitiesAdapter)); } + private void createRowCustomShortcuts() { + if (!getResources().getBoolean(R.bool.ENABLE_RAW_INTENTS)) { + return; // Don't do this at all. + } + // Add a row for credits + OptionsCardPresenter optionsCardPresenter = new OptionsCardPresenter(); + ArrayObjectAdapter optionsRowAdapter = new ArrayObjectAdapter(optionsCardPresenter); + if (getResources().getBoolean(R.bool.ENABLE_WEB_BOOKMARKS)) { + optionsRowAdapter.add(new SettingOption( + getResources().getDrawable(R.drawable.web_bookmark), + getString(R.string.generate_web_bookmark), + new SettingOption.OnClickListener() { + @Override + public void onClick() { + ShortcutGeneratorDialogs.initWebBookmarkDialog(getActivity()); + } + } + )); + } + if (getResources().getBoolean(R.bool.ENABLE_FOLDERS)) { + optionsRowAdapter.add(new SettingOption( + getResources().getDrawable(R.drawable.folder), + getString(R.string.generate_folder), + new SettingOption.OnClickListener() { + @Override + public void onClick() { + new MaterialDialog.Builder(new ContextThemeWrapper(getActivity(), R.style.dialog_theme)) + .title(R.string.generate_folder) + .customView(R.layout.dialog_folder, false) + .onNegative(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + String tag = ((EditText) dialog.getCustomView().findViewById(R.id.tag)).getText().toString(); + + Toast.makeText(getActivity(), R.string.starting_download, Toast.LENGTH_SHORT).show(); + } + }) + .positiveText(R.string.generate_shortcut) + .show(); + } + } + )); + } + if (getResources().getBoolean(R.bool.ENABLE_SETTINGS)) { + optionsRowAdapter.add(new SettingOption( + getResources().getDrawable(R.drawable.sys_settings), + getString(R.string.generate_settings), + new SettingOption.OnClickListener() { + @Override + public void onClick() { + new MaterialDialog.Builder(new ContextThemeWrapper(getActivity(), R.style.dialog_theme)) + .title(R.string.generate_settings) + .customView(R.layout.dialog_folder, false) + .onNegative(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + String tag = ((EditText) dialog.getCustomView().findViewById(R.id.tag)).getText().toString(); + + Toast.makeText(getActivity(), R.string.starting_download, Toast.LENGTH_SHORT).show(); + } + }) + .positiveText(R.string.generate_shortcut) + .show(); + } + } + )); + } + if (getResources().getBoolean(R.bool.ENABLE_APP_DEEPLINKS)) { + optionsRowAdapter.add(new SettingOption( + getResources().getDrawable(R.drawable.deep_link), + getString(R.string.generate_deep_link), + new SettingOption.OnClickListener() { + @Override + public void onClick() { + new MaterialDialog.Builder(new ContextThemeWrapper(getActivity(), R.style.dialog_theme)) + .title(R.string.generate_deep_link) + .customView(R.layout.dialog_folder, false) + .onNegative(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + String tag = ((EditText) dialog.getCustomView().findViewById(R.id.tag)).getText().toString(); + + Toast.makeText(getActivity(), R.string.starting_download, Toast.LENGTH_SHORT).show(); + } + }) + .positiveText(R.string.generate_shortcut) + .show(); + } + } + )); + } + if (getResources().getBoolean(R.bool.ENABLE_FILE_URIS)) { + optionsRowAdapter.add(new SettingOption( + getResources().getDrawable(R.drawable.file_location), + getString(R.string.generate_file_shortcut), + new SettingOption.OnClickListener() { + @Override + public void onClick() { + new MaterialDialog.Builder(new ContextThemeWrapper(getActivity(), R.style.dialog_theme)) + .title(R.string.generate_file_shortcut) + .customView(R.layout.dialog_folder, false) + .onNegative(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + String tag = ((EditText) dialog.getCustomView().findViewById(R.id.tag)).getText().toString(); + + Toast.makeText(getActivity(), R.string.starting_download, Toast.LENGTH_SHORT).show(); + } + }) + .positiveText(R.string.generate_shortcut) + .show(); + } + } + )); + } + + HeaderItem header = new HeaderItem(2, getString(R.string.header_custom)); + mRowsAdapter.add(new ListRow(header, optionsRowAdapter)); + } + private void createRowMisc() { // Add a row for credits OptionsCardPresenter optionsCardPresenter = new OptionsCardPresenter(); @@ -338,7 +434,7 @@ public void onClick() { new MaterialDialog.Builder(new ContextThemeWrapper(getActivity(), R.style.dialog_theme)) .title(R.string.sideloadtag) .customView(R.layout.dialog_sideload_tag, false) - .onNegative(new MaterialDialog.SingleButtonCallback() { + .onPositive(new MaterialDialog.SingleButtonCallback() { @Override public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { String tag = ((EditText) dialog.getCustomView().findViewById(R.id.tag)).getText().toString(); @@ -474,6 +570,7 @@ public void onItemClicked(Presenter.ViewHolder itemViewHolder, final Object item } else if (item instanceof SettingOption) { ((SettingOption) item).getClickListener().onClick(); } else if (item instanceof File) { + Log.d(TAG, "Open file " + ((File) item).getAbsolutePath()); mApkDownloadHelper.install((File) item); } else if (item instanceof ResolveInfo) { GenerateShortcutHelper.begin(mMainActivity, (ResolveInfo) item); diff --git a/app/src/main/java/news/androidtv/tvapprepo/iconography/IconsTask.java b/app/src/main/java/news/androidtv/tvapprepo/iconography/IconsTask.java new file mode 100644 index 0000000..635e9c2 --- /dev/null +++ b/app/src/main/java/news/androidtv/tvapprepo/iconography/IconsTask.java @@ -0,0 +1,130 @@ +package news.androidtv.tvapprepo.iconography; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.res.Resources; +import android.content.res.XmlResourceParser; +import android.graphics.drawable.Drawable; +import android.util.Log; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Created by Nick on 4/23/2017. + * + * This class has methods that allow it to traverse icon packs installed on your device. These + * icon packs are ultimately XML files that contain certain properties in the `appfilter.xml`. + * + * + * + * We are adding more properties to support TVs. Each XML file can have a `banner` boolean attribute. + * Alternatively, one can them placed in `appfilterbanners.xml` + * + * We need to remove the "ComponentInfo" from that attribute. + * ComponentInfo{com.android.browser/com.android.browser.BrowserActivity} + * ^ (14) ^ (-1) + * + * + * For some additional references: + * * https://github.com/iamareebjamal/scratch_icon_pack_source/blob/master/app/src/main/res/xml/appfilter.xml + * * http://stackoverflow.com/questions/24937890/using-icon-packs-in-my-app + * * https://github.com/CyanogenMod/android_packages_apps_Trebuchet/ + * * https://github.com/googlesamples/androidtv-sample-inputs/blob/master/app/src/main/res/raw/rich_tv_input_xmltv_feed.xml + */ + +public class IconsTask { + private static final boolean DEBUG = true; + private static final String TAG = IconsTask.class.getSimpleName(); + + private static final String INTENT_FILTER_ICON_PACKS = "org.adw.launcher.THEMES"; + + public static void getIconsForComponentName(Activity activity, ComponentName filter, IconsReceivedCallback iconsReceivedCallback) { + List iconList = new ArrayList<>(); + List iconPacks = + activity.getPackageManager().queryIntentActivities(new Intent(INTENT_FILTER_ICON_PACKS), PackageManager.GET_META_DATA); + if (DEBUG) { + Log.d(TAG, "Found " + iconPacks.size() + " icon packs"); + } + for (ResolveInfo app : iconPacks) { + if (DEBUG) { + Log.d(TAG, "Scan icon pack " + app.activityInfo.packageName + ", " + app.activityInfo.applicationInfo.packageName); + } + iconList.addAll(scanIconPack(activity, "appfilter", app.activityInfo.applicationInfo.packageName, filter)); + iconList.addAll(scanIconPack(activity, "appfilterbanner", app.activityInfo.applicationInfo.packageName, filter)); + } + if (DEBUG) { + Log.d(TAG, "Sending callback with " + iconList.size() + " items"); + } + iconsReceivedCallback.onIcons(iconList.toArray(new PackedIcon[iconList.size()])); + } + + private static List scanIconPack(Activity activity, String filename, String packageName, ComponentName filter) { + List iconList = new ArrayList<>(); + try { + Resources iconResources = activity.getPackageManager().getResourcesForApplication(packageName); + int xmlId = iconResources.getIdentifier(filename, "xml", packageName); + if (DEBUG) { + Log.d(TAG, "Read XML file " + xmlId); + } + if (xmlId == 0) { + return iconList; + } + XmlResourceParser resourceParser = iconResources.getXml(xmlId); + try { + while (resourceParser.next() != XmlPullParser.END_DOCUMENT) { + if (resourceParser.getName() != null && resourceParser.getName().equals("item")) { + // Get properties + boolean validApp = false; + String drawableName = ""; + boolean banner = false; + for (int i = 0; i < resourceParser.getAttributeCount(); i++) { + String attr = resourceParser.getAttributeName(i); + String value = resourceParser.getAttributeValue(i); + + if (attr.equals("component") && + value.substring(14, value.length() - 1).equals(filter.flattenToString())) { + validApp = true; + } else if (attr.equals("drawable")) { + drawableName = value; + } else if (attr.equals("banner")) { + banner = value.toLowerCase().equals("true"); + } + + if (i + 1 == resourceParser.getAttributeCount()) { + // Last element + if (validApp) { + int drawableId = iconResources.getIdentifier(drawableName, "drawable", packageName); + Drawable icon = iconResources.getDrawable(drawableId); + iconList.add(new PackedIcon(icon, banner)); + if (DEBUG) { + Log.d(TAG, "Adding an icon for " + drawableName + " from " + packageName + ": " + drawableId); + } + } + validApp = false; + drawableName = ""; + banner = false; + } + } + } + } + } catch (XmlPullParserException | IOException e) { + e.printStackTrace(); + } + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + } + return iconList; + } + + public interface IconsReceivedCallback { + void onIcons(PackedIcon[] icons); + } +} diff --git a/app/src/main/java/news/androidtv/tvapprepo/iconography/PackedIcon.java b/app/src/main/java/news/androidtv/tvapprepo/iconography/PackedIcon.java new file mode 100644 index 0000000..9ba7c5d --- /dev/null +++ b/app/src/main/java/news/androidtv/tvapprepo/iconography/PackedIcon.java @@ -0,0 +1,39 @@ +package news.androidtv.tvapprepo.iconography; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; + +/** + * Created by Nick on 4/23/2017. + * + * A simple model abstraction that contains a few custom parameters. + */ +public class PackedIcon { + public final Drawable icon; + public final boolean isBanner; // For 'banner-packs' + + public PackedIcon(Drawable icon, boolean isBanner) { + this.icon = icon; + this.isBanner = isBanner; + } + + public Bitmap getBitmap() { + if (icon instanceof BitmapDrawable) { + return ((BitmapDrawable) icon).getBitmap(); + } + + int width = icon.getIntrinsicWidth(); + width = width > 0 ? width : 1; + int height = icon.getIntrinsicHeight(); + height = height > 0 ? height : 1; + + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + icon.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + icon.draw(canvas); + + return bitmap; + } +} diff --git a/app/src/main/java/news/androidtv/tvapprepo/intents/IntentUriGenerator.java b/app/src/main/java/news/androidtv/tvapprepo/intents/IntentUriGenerator.java new file mode 100644 index 0000000..90a0d46 --- /dev/null +++ b/app/src/main/java/news/androidtv/tvapprepo/intents/IntentUriGenerator.java @@ -0,0 +1,58 @@ +package news.androidtv.tvapprepo.intents; + +import android.content.ComponentName; +import android.content.Intent; +import android.net.Uri; +import android.webkit.MimeTypeMap; + +import java.io.File; + +/** + * Created by Nick on 4/20/2017. + * + * Generates Intent URIs through static methods. + */ +public class IntentUriGenerator { + public static String generateWebBookmark(String url) { + Intent i = new Intent(Intent.ACTION_VIEW); + i.setData(Uri.parse(url)); + return i.toUri(Intent.URI_INTENT_SCHEME); + } + + public static String generateVideoPlayback(File myFile) { + MimeTypeMap myMime = MimeTypeMap.getSingleton(); + Intent newIntent = new Intent(Intent.ACTION_VIEW); + String mimeType = myMime.getMimeTypeFromExtension(fileExt(myFile.getAbsolutePath()).substring(1)); + newIntent.setDataAndType(Uri.fromFile(myFile),mimeType); + newIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return newIntent.toUri(Intent.URI_INTENT_SCHEME); + } + + public static String generateFileOpen(File myFile) { + return generateVideoPlayback(myFile); + } + + public static String generateActivityShortcut(ComponentName componentName) { + Intent newIntent = new Intent(); + newIntent.setComponent(componentName); + return newIntent.toUri(Intent.URI_INTENT_SCHEME); + } + + private static String fileExt(String url) { + if (url.indexOf("?") > -1) { + url = url.substring(0, url.indexOf("?")); + } + if (url.lastIndexOf(".") == -1) { + return null; + } else { + String ext = url.substring(url.lastIndexOf(".") + 1); + if (ext.indexOf("%") > -1) { + ext = ext.substring(0, ext.indexOf("%")); + } + if (ext.indexOf("/") > -1) { + ext = ext.substring(0, ext.indexOf("/")); + } + return ext.toLowerCase(); + } + } +} diff --git a/app/src/main/java/news/androidtv/tvapprepo/model/AdvancedOptions.java b/app/src/main/java/news/androidtv/tvapprepo/model/AdvancedOptions.java index a1d1aab..50e935f 100644 --- a/app/src/main/java/news/androidtv/tvapprepo/model/AdvancedOptions.java +++ b/app/src/main/java/news/androidtv/tvapprepo/model/AdvancedOptions.java @@ -1,12 +1,18 @@ package news.androidtv.tvapprepo.model; +import android.app.Activity; import android.content.Context; import android.graphics.Bitmap; import android.os.Handler; import android.os.Looper; +import android.os.Parcel; +import android.os.Parcelable; import com.bumptech.glide.Glide; +import org.json.JSONException; +import org.json.JSONObject; + import java.io.ByteArrayOutputStream; import java.util.concurrent.ExecutionException; @@ -15,10 +21,15 @@ /** * Created by Nick on 3/20/2017. A model for storing advanced options in generating shortcuts. */ -public class AdvancedOptions { +public class AdvancedOptions implements Parcelable { private volatile int mReady = 0; private String mCategory = ""; + private String mIconUrl = ""; private String mBannerUrl = ""; + private String mIntentUri = ""; + private String mCustomLabel = ""; + private boolean mUnique = false; + private byte[] mIconData = null; private byte[] mBannerData = null; private Context mContext = null; @@ -49,10 +60,66 @@ public AdvancedOptions setIsGame(boolean isGame) { return this; } + public AdvancedOptions setIntentUri(String intentUri) { + if (intentUri.length() < 20 || intentUri.length() > 300) { + throw new StringLengthException(); + } + mIntentUri = intentUri; + return this; + } + + public AdvancedOptions setUniquePackageName(boolean isUnique) { + mUnique = isUnique; + return this; + } + + public AdvancedOptions setCustomLabel(String label) { + mCustomLabel = label; + return this; + } + + public AdvancedOptions setIconUrl(String iconUrl) { + if (iconUrl == null || iconUrl.isEmpty()) { + // Exit early. + return this; + } + mReady++; + mIconUrl = iconUrl; + // Download from Glide. + downloadBanner(mContext, iconUrl, new GlideCallback() { + @Override + public void onDone(byte[] binaryData) { + mIconData = binaryData; + mReady--; + } + }); + return this; + } + + public AdvancedOptions setBannerBitmap(Bitmap bitmap) { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); + byte[] results = stream.toByteArray(); + mBannerData = results; + return this; + } + + public AdvancedOptions setIconBitmap(Bitmap bitmap) { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); + byte[] results = stream.toByteArray(); + mIconData = results; + return this; + } + public boolean isReady() { return mReady == 0; } + public byte[] getIcon() { + return mIconData; + } + public byte[] getBanner() { return mBannerData; } @@ -65,6 +132,22 @@ public String getCategory() { return mCategory; } + public String getIntentUri() { + return mIntentUri; + } + + public String getCustomLabel() { + return mCustomLabel; + } + + public boolean isUnique() { + return mUnique; + } + + public String getIconUrl() { + return mIconUrl; + } + private void downloadBanner(final Context context, final String url, final GlideCallback callback) { new Thread(new Runnable() { @Override @@ -87,7 +170,100 @@ public void run() { }).start(); } + @Override + public String toString() { + return "Name=" + mCustomLabel + ", category=" + mCategory + ", iconUrl=" + mIconUrl + + ", bannerUrl=" + mBannerUrl + ", iconData=" + (mIconData != null) + + ", bannerData=" + (mBannerData != null); + } + + public String serialize() { + JSONObject object = new JSONObject(); + try { + object.put("customLabel", mCustomLabel); + object.put("category", mCategory); + object.put("iconUrl", mIconUrl); + object.put("bannerUrl", mBannerUrl); + object.put("isUnique", mUnique); + object.put("intentUri", mIntentUri); + } catch (JSONException e) { + e.printStackTrace(); + } + return object.toString(); + } + + public static AdvancedOptions deserialize(Activity activity, String serialization) { + AdvancedOptions options = new AdvancedOptions(activity); + try { + JSONObject object = new JSONObject(serialization); + options.setCustomLabel(object.optString("customLabel")); + options.mCategory = object.optString("category"); + options.setIconUrl(object.optString("iconUrl")); + options.setBannerUrl(object.optString("bannerUrl")); + options.setUniquePackageName(object.optBoolean("isUnique")); + options.setIntentUri(object.optString("intentUri")); + } catch (JSONException e) { + e.printStackTrace(); + } + return options; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(mCustomLabel); + dest.writeString(mCategory); + dest.writeString(mIconUrl); + dest.writeString(mBannerUrl); + dest.writeByte((byte) (mUnique ? 1 : 0)); + dest.writeString(mIntentUri); + if (mIconData == null) { + mIconData = new byte[0]; + } + if (mBannerData == null) { + mBannerData = new byte[0]; + } + dest.writeInt(mIconData.length); + dest.writeByteArray(mIconData); + dest.writeInt(mBannerData.length); + dest.writeByteArray(mBannerData); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + public AdvancedOptions createFromParcel(Parcel in) { + return new AdvancedOptions(in); + } + + public AdvancedOptions[] newArray(int size) { + return new AdvancedOptions[size]; + } + }; + + private AdvancedOptions(Parcel in) { + mCustomLabel = in.readString(); + mCategory = in.readString(); + mIconUrl = in.readString(); + mBannerUrl = in.readString(); + mUnique = in.readByte() == 1; + mIntentUri = in.readString(); + mIconData = new byte[in.readInt()]; + in.readByteArray(mIconData); + mBannerData = new byte[in.readInt()]; + in.readByteArray(mBannerData); + } + private interface GlideCallback { void onDone(byte[] binaryData); } + + public class StringLengthException extends RuntimeException { + public StringLengthException() { + super("Intent URI length must be between 20 and 300 characters"); + } + } } diff --git a/app/src/main/java/news/androidtv/tvapprepo/ui/ShortcutGeneratorDialogs.java b/app/src/main/java/news/androidtv/tvapprepo/ui/ShortcutGeneratorDialogs.java new file mode 100644 index 0000000..5f915cd --- /dev/null +++ b/app/src/main/java/news/androidtv/tvapprepo/ui/ShortcutGeneratorDialogs.java @@ -0,0 +1,49 @@ +package news.androidtv.tvapprepo.ui; + +import android.app.Activity; +import android.content.Intent; +import android.support.annotation.NonNull; +import android.util.Log; +import android.view.ContextThemeWrapper; +import android.widget.EditText; +import android.widget.Toast; + +import com.afollestad.materialdialogs.DialogAction; +import com.afollestad.materialdialogs.MaterialDialog; + +import news.androidtv.tvapprepo.R; +import news.androidtv.tvapprepo.intents.IntentUriGenerator; +import news.androidtv.tvapprepo.model.AdvancedOptions; +import news.androidtv.tvapprepo.utils.GenerateShortcutHelper; + +/** + * Created by Nick on 4/23/2017. + */ + +public class ShortcutGeneratorDialogs { + private static final String TAG = ShortcutGeneratorDialogs.class.getSimpleName(); + + public static void initWebBookmarkDialog(final Activity activity) { + new MaterialDialog.Builder(new ContextThemeWrapper(activity, R.style.dialog_theme)) + .title(R.string.generate_web_bookmark) + .customView(R.layout.dialog_web_bookmark, false) + .onPositive(new MaterialDialog.SingleButtonCallback() { + @Override + public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) { + String tag = ((EditText) dialog.getCustomView().findViewById(R.id.tag)).getText().toString(); + if (!tag.contains("http://") || !tag.contains("https://")) { + tag = "http://" + tag; + } + String label = tag.replaceAll("(http://)|(https://)", ""); + Log.d(TAG, IntentUriGenerator.generateWebBookmark(tag)); + AdvancedOptions options = new AdvancedOptions(activity) + .setIntentUri(IntentUriGenerator.generateWebBookmark(tag)) + .setIconUrl("https://raw.githubusercontent.com/ITVlab/TvAppRepo/master/promo/graphics/icon.png") // TODO Replace icon url + .setCustomLabel(label); + GenerateShortcutHelper.begin(activity, label, options); + } + }) + .positiveText(R.string.generate_shortcut) + .show(); + } +} diff --git a/app/src/main/java/news/androidtv/tvapprepo/utils/GenerateShortcutHelper.java b/app/src/main/java/news/androidtv/tvapprepo/utils/GenerateShortcutHelper.java index d1000b3..b71c367 100644 --- a/app/src/main/java/news/androidtv/tvapprepo/utils/GenerateShortcutHelper.java +++ b/app/src/main/java/news/androidtv/tvapprepo/utils/GenerateShortcutHelper.java @@ -2,37 +2,51 @@ import android.app.Activity; import android.app.Dialog; +import android.content.ComponentName; import android.content.DialogInterface; +import android.content.Intent; import android.content.pm.ResolveInfo; import android.os.Handler; import android.os.Looper; import android.support.v7.app.AlertDialog; +import android.text.Layout; +import android.util.Log; import android.view.ContextThemeWrapper; import android.view.View; import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.LinearLayout; import android.widget.Switch; import android.widget.Toast; import com.android.volley.NetworkResponse; import com.android.volley.VolleyError; +import com.google.android.gms.ads.AdListener; +import com.google.android.gms.ads.AdRequest; +import com.google.android.gms.ads.InterstitialAd; +import com.google.android.gms.ads.MobileAds; import org.json.JSONException; import org.json.JSONObject; import news.androidtv.tvapprepo.R; +import news.androidtv.tvapprepo.activities.AdvancedShortcutActivity; import news.androidtv.tvapprepo.download.ApkDownloadHelper; +import news.androidtv.tvapprepo.iconography.IconsTask; +import news.androidtv.tvapprepo.iconography.PackedIcon; import news.androidtv.tvapprepo.model.AdvancedOptions; /** * Created by Nick Felker on 3/20/2017. */ public class GenerateShortcutHelper { + private static final String TAG = GenerateShortcutHelper.class.getSimpleName(); + private static final String KEY_BUILD_STATUS = "build_ok"; private static final String KEY_APP_OBJ = "app"; private static final String KEY_DOWNLOAD_URL = "download_link"; public static void begin(final Activity activity, final ResolveInfo resolveInfo) { - new AlertDialog.Builder(new ContextThemeWrapper(activity, R.style.dialog_theme)) .setTitle(activity.getString(R.string.title_shortcut_generator, resolveInfo.activityInfo.applicationInfo.loadLabel(activity.getPackageManager()))) @@ -54,33 +68,45 @@ public void onClick(DialogInterface dialog, int which) { .show(); } - private static void openAdvancedOptions(final Activity activity, final ResolveInfo resolveInfo) { - final AlertDialog dialog = new AlertDialog.Builder(new ContextThemeWrapper(activity, R.style.dialog_theme)) - .setTitle(R.string.advanced_options) - .setView(R.layout.dialog_app_shortcut_editor) + public static void begin(final Activity activity, final String label, final AdvancedOptions options) { + new AlertDialog.Builder(new ContextThemeWrapper(activity, R.style.dialog_theme)) + .setTitle(activity.getString(R.string.title_shortcut_generator, label)) + .setMessage(R.string.shortcut_generator_info) + .setPositiveButton(R.string.create_shortcut, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + generateShortcut(activity, null, options); + } + }) + .setNeutralButton(R.string.advanced, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // Open a new dialog + openAdvancedOptions(activity, null, options); + } + }) .setNegativeButton(R.string.cancel, null) - .create(); - dialog.setButton(Dialog.BUTTON_POSITIVE, - activity.getString(R.string.create_shortcut), - new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialogInterface, int which) { - View editor = dialog.getWindow().getDecorView(); - AdvancedOptions options = new AdvancedOptions(activity); - String bannerUrl = - ((EditText) editor.findViewById(R.id.edit_banner)).getText().toString(); - boolean isGame = ((Switch) editor.findViewById(R.id.switch_isgame)).isChecked(); - options.setBannerUrl(bannerUrl).setIsGame(isGame); - generateShortcut(activity, resolveInfo, options); - } - }); - dialog.show(); + .show(); + } + + private static void openAdvancedOptions(final Activity activity, final ResolveInfo resolveInfo) { + openAdvancedOptions(activity, resolveInfo, null); + } + + private static void openAdvancedOptions(final Activity activity, final ResolveInfo resolveInfo, AdvancedOptions options) { + Intent editorPanel = new Intent(activity, AdvancedShortcutActivity.class); + editorPanel.putExtra(AdvancedShortcutActivity.EXTRA_RESOLVE_INFO, resolveInfo); + if (options != null) { + editorPanel.putExtra(AdvancedShortcutActivity.EXTRA_ADVANCED_OPTIONS, options); + } + activity.startActivity(editorPanel); } private static void downloadShortcutApk(Activity activity, NetworkResponse response, Object item) { JSONObject data = null; try { data = new JSONObject(new String(response.data)); + Log.d(TAG, data.toString()); if (data.getBoolean(KEY_BUILD_STATUS)) { String downloadLink = data.getJSONObject(KEY_APP_OBJ).getString(KEY_DOWNLOAD_URL); ApkDownloadHelper apkDownloadHelper = new ApkDownloadHelper(activity); @@ -88,27 +114,31 @@ private static void downloadShortcutApk(Activity activity, NetworkResponse respo if (activity == null) { throw new NullPointerException("Activity variable doesn't exist"); } - apkDownloadHelper.startDownload(downloadLink, - ((ResolveInfo) item).activityInfo.applicationInfo - .loadLabel(activity.getPackageManager()).toString()); + apkDownloadHelper.startDownload(downloadLink); } else { Toast.makeText(activity, R.string.err_build_failed, Toast.LENGTH_SHORT).show(); } } catch (JSONException e) { e.printStackTrace(); } catch (NullPointerException e) { - throw new NullPointerException(e.getMessage() + - "\nSomething odd is happening for " + - ((ResolveInfo) item).activityInfo.packageName - + "\n" + data.toString()); + if (item instanceof ResolveInfo) { + throw new NullPointerException(e.getMessage() + + "\nSomething odd is happening for " + + ((ResolveInfo) item).activityInfo.packageName + + "\n" + data.toString()); + } else { + throw new NullPointerException(e.getMessage() + + "\nSomething odd is happening" + + "\n" + data.toString()); + } } } - private static void generateShortcut(final Activity activity, final ResolveInfo resolveInfo) { + public static void generateShortcut(final Activity activity, final ResolveInfo resolveInfo) { generateShortcut(activity, resolveInfo, new AdvancedOptions(activity)); } - private static void generateShortcut(final Activity activity, final ResolveInfo resolveInfo, + public static void generateShortcut(final Activity activity, final ResolveInfo resolveInfo, final AdvancedOptions options) { if (!options.isReady()) { // Delay until we complete all web operations @@ -123,13 +153,15 @@ public void run() { Toast.makeText(activity, R.string.msg_pls_wait, Toast.LENGTH_SHORT).show(); + + final InterstitialAd video = showVisualAd(activity); + ShortcutPostTask.generateShortcut(activity, resolveInfo, options, new ShortcutPostTask.Callback() { @Override public void onResponse(NetworkResponse response) { - // TODO Hide ad downloadShortcutApk(activity, response, resolveInfo); } @@ -140,6 +172,33 @@ public void onError(VolleyError error) { Toast.LENGTH_SHORT).show(); } }); - // TODO Show visual ad + } + + static InterstitialAd showVisualAd(Activity activity) { + final InterstitialAd video = new InterstitialAd(activity); + video.setAdUnitId(activity.getString(R.string.interstitial_ad_unit_id)); + AdRequest adRequest = new AdRequest.Builder() + .addTestDevice(AdRequest.DEVICE_ID_EMULATOR) + .build(); + video.loadAd(adRequest); + Log.d(TAG, "Loading ad"); + + video.setAdListener(new AdListener() { + @Override + public void onAdLoaded() { + super.onAdLoaded(); + Log.d(TAG, "Ad loaded"); + // Show video as soon as possible + video.show(); + } + + @Override + public void onAdClosed() { + super.onAdClosed(); + Log.d(TAG, "Ad closed"); + } + }); + + return video; } } diff --git a/app/src/main/java/news/androidtv/tvapprepo/utils/ShortcutPostTask.java b/app/src/main/java/news/androidtv/tvapprepo/utils/ShortcutPostTask.java index d4a197a..1d9e02b 100644 --- a/app/src/main/java/news/androidtv/tvapprepo/utils/ShortcutPostTask.java +++ b/app/src/main/java/news/androidtv/tvapprepo/utils/ShortcutPostTask.java @@ -54,12 +54,14 @@ public class ShortcutPostTask { private static final String TAG = ShortcutPostTask.class.getSimpleName(); private static final String SUBMISSION_URL = - "http://atvlauncher.trekgonewild.de/index_test.php"; + "http://atvlauncher.trekgonewild.de/index_tvapprepo.php"; private static final String FORM_APP_NAME = "app_name"; private static final String FORM_APP_PACKAGE = "app_package"; private static final String FORM_APP_CATEGORY = "app_category"; private static final String FORM_APP_LOGO = "app_logo"; private static final String FORM_APP_BANNER = "app_banner"; + private static final String FORM_UNIQUE_PACKAGE_NAME = "unique"; + private static final String FORM_INTENT_URI = "app_intent"; private static final String FORM_JSON = "json"; public static final String CATEGORY_GAMES = "games"; public static final String CATEGORY_APPS = "apps"; @@ -94,12 +96,23 @@ public void onErrorResponse(VolleyError error) { @Override protected Map getParams(){ Map params = new HashMap<>(); - params.put(FORM_APP_NAME, app.activityInfo.loadLabel(context.getPackageManager()).toString()); - params.put(FORM_APP_PACKAGE, app.activityInfo.applicationInfo.packageName); - params.put(FORM_APP_CATEGORY, CATEGORY_APPS); + if (app != null) { + params.put(FORM_APP_NAME, app.activityInfo.loadLabel(context.getPackageManager()).toString()); + params.put(FORM_APP_PACKAGE, app.activityInfo.applicationInfo.packageName); + } else if (!options.getCustomLabel().isEmpty()) { + params.put(FORM_APP_NAME, options.getCustomLabel()); + params.put(FORM_APP_PACKAGE, "news.androidtv.tvapprepo"); // Use our own package name + options.setUniquePackageName(true); // Force to unique. + } if (!options.getCategory().isEmpty()) { params.put(FORM_APP_CATEGORY, options.getCategory()); + } else { + params.put(FORM_APP_CATEGORY, CATEGORY_APPS); + } + if (!options.getIntentUri().isEmpty()) { + params.put(FORM_INTENT_URI, options.getIntentUri()); } + params.put(FORM_UNIQUE_PACKAGE_NAME, options.isUnique()+""); params.put(FORM_JSON, "true"); return params; } @@ -109,10 +122,15 @@ protected Map getByteData() { Map params = new HashMap<>(); // file name could found file base or direct access from real path // for now just get bitmap data from ImageView - params.put(FORM_APP_LOGO, new DataPart("file_avatar.png", - VolleyMultipartRequest.getFileDataFromDrawable(context, - app.activityInfo.loadIcon(context.getPackageManager())), - "image/png")); + if (options.getIcon() != null) { + params.put(FORM_APP_LOGO, new DataPart("file_avatar.png", options.getIcon(), + "image/png")); + } else if (app != null) { + params.put(FORM_APP_LOGO, new DataPart("file_avatar.png", + VolleyMultipartRequest.getFileDataFromDrawable(context, + app.activityInfo.loadIcon(context.getPackageManager())), + "image/png")); + } if (options.getBanner() != null) { params.put(FORM_APP_BANNER, new DataPart("file_avatar.png", options.getBanner(), "image/png")); diff --git a/app/src/main/res/anim/side_panel_enter.xml b/app/src/main/res/anim/side_panel_enter.xml new file mode 100644 index 0000000..a7e036d --- /dev/null +++ b/app/src/main/res/anim/side_panel_enter.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/side_panel_exit.xml b/app/src/main/res/anim/side_panel_exit.xml new file mode 100644 index 0000000..1b31cd8 --- /dev/null +++ b/app/src/main/res/anim/side_panel_exit.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/deep_link.png b/app/src/main/res/drawable/deep_link.png new file mode 100644 index 0000000..6721f48 Binary files /dev/null and b/app/src/main/res/drawable/deep_link.png differ diff --git a/app/src/main/res/drawable/file_location.png b/app/src/main/res/drawable/file_location.png new file mode 100644 index 0000000..aa1bfdd Binary files /dev/null and b/app/src/main/res/drawable/file_location.png differ diff --git a/app/src/main/res/drawable/folder.png b/app/src/main/res/drawable/folder.png new file mode 100644 index 0000000..4df08dc Binary files /dev/null and b/app/src/main/res/drawable/folder.png differ diff --git a/app/src/main/res/drawable/sys_settings.png b/app/src/main/res/drawable/sys_settings.png new file mode 100644 index 0000000..b6b42af Binary files /dev/null and b/app/src/main/res/drawable/sys_settings.png differ diff --git a/app/src/main/res/drawable/web_bookmark.png b/app/src/main/res/drawable/web_bookmark.png new file mode 100644 index 0000000..f0a4cd4 Binary files /dev/null and b/app/src/main/res/drawable/web_bookmark.png differ diff --git a/app/src/main/res/layout/activity_advanced.xml b/app/src/main/res/layout/activity_advanced.xml new file mode 100644 index 0000000..228b17e --- /dev/null +++ b/app/src/main/res/layout/activity_advanced.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + +