/* * Copyright (C) 2016-2017 Peter Serwylo * Copyright (C) 2017 Christine Emrich * Copyright (C) 2017 Hans-Christoph Steiner * Copyright (C) 2018 Senecto Limited * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 3 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, * MA 02110-1301, USA. */ package org.fdroid.fdroid.views.main; import android.app.SearchManager; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.TextUtils; import android.view.ViewGroup; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.badge.BadgeDrawable; import com.google.android.material.bottomnavigation.BottomNavigationView; import org.fdroid.fdroid.AppUpdateStatusManager; import org.fdroid.fdroid.AppUpdateStatusManager.AppUpdateStatus; import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.NfcHelper; import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.R; import org.fdroid.fdroid.UpdateService; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.NewRepoConfig; import org.fdroid.fdroid.nearby.SDCardScannerService; import org.fdroid.fdroid.nearby.SwapService; import org.fdroid.fdroid.nearby.SwapWorkflowActivity; import org.fdroid.fdroid.nearby.TreeUriScannerIntentService; import org.fdroid.fdroid.nearby.WifiStateChangeService; import org.fdroid.fdroid.net.DownloaderService; import org.fdroid.fdroid.views.AppDetailsActivity; import org.fdroid.fdroid.views.ManageReposActivity; import org.fdroid.fdroid.views.apps.AppListActivity; /** * Main view shown to users upon starting F-Droid. *

* Shows a bottom navigation bar, with the following entries: * + Whats new * + Categories list * + App swap * + Updates * + Settings *

* Users navigate between items by using the bottom navigation bar, or by swiping left and right. * When switching from one screen to the next, we stay within this activity. The new screen will * get inflated (if required) */ public class MainActivity extends AppCompatActivity { private static final String TAG = "MainActivity"; public static final String EXTRA_VIEW_UPDATES = "org.fdroid.fdroid.views.main.MainActivity.VIEW_UPDATES"; public static final String EXTRA_VIEW_NEARBY = "org.fdroid.fdroid.views.main.MainActivity.VIEW_NEARBY"; public static final String EXTRA_VIEW_SETTINGS = "org.fdroid.fdroid.views.main.MainActivity.VIEW_SETTINGS"; static final int REQUEST_LOCATION_PERMISSIONS = 0xEF0F; static final int REQUEST_STORAGE_PERMISSIONS = 0xB004; public static final int REQUEST_STORAGE_ACCESS = 0x40E5; private static final String ADD_REPO_INTENT_HANDLED = "addRepoIntentHandled"; private static final String ACTION_ADD_REPO = "org.fdroid.fdroid.MainActivity.ACTION_ADD_REPO"; public static final String ACTION_REQUEST_SWAP = "requestSwap"; private RecyclerView pager; private MainViewAdapter adapter; private BottomNavigationView bottomNavigation; private BadgeDrawable updatesBadge; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { FDroidApp fdroidApp = (FDroidApp) getApplication(); fdroidApp.applyPureBlackBackgroundInDarkTheme(this); super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); adapter = new MainViewAdapter(this); pager = (RecyclerView) findViewById(R.id.main_view_pager); pager.setHasFixedSize(true); pager.setLayoutManager(new NonScrollingHorizontalLayoutManager(this)); pager.setAdapter(adapter); // Without this, the focus is completely busted on pre 15 devices. Trying to use them // without this ends up with each child view showing for a fraction of a second, then // reverting back to the "Latest" screen again, in completely non-deterministic ways. if (Build.VERSION.SDK_INT <= 15) { pager.setDescendantFocusability(ViewGroup.FOCUS_BEFORE_DESCENDANTS); } bottomNavigation = (BottomNavigationView) findViewById(R.id.bottom_navigation); bottomNavigation.setOnNavigationItemSelectedListener(item -> { pager.scrollToPosition(item.getOrder()); if (item.getItemId() == R.id.nearby) { NearbyViewBinder.updateUsbOtg(MainActivity.this); } return true; }); updatesBadge = bottomNavigation.getOrCreateBadge(R.id.updates); updatesBadge.setVisible(false); IntentFilter updateableAppsFilter = new IntentFilter(AppUpdateStatusManager.BROADCAST_APPSTATUS_LIST_CHANGED); updateableAppsFilter.addAction(AppUpdateStatusManager.BROADCAST_APPSTATUS_CHANGED); updateableAppsFilter.addAction(AppUpdateStatusManager.BROADCAST_APPSTATUS_REMOVED); LocalBroadcastManager.getInstance(this).registerReceiver(onUpdateableAppsChanged, updateableAppsFilter); initialRepoUpdateIfRequired(); Intent intent = getIntent(); handleSearchOrAppViewIntent(intent); } private void setSelectedMenuInNav(int menuId) { int position = adapter.adapterPositionFromItemId(menuId); pager.scrollToPosition(position); bottomNavigation.setSelectedItemId(position); } private void initialRepoUpdateIfRequired() { if (Preferences.get().isIndexNeverUpdated() && !UpdateService.isUpdating()) { Utils.debugLog(TAG, "We haven't done an update yet. Forcing repo update."); UpdateService.updateNow(this); } } @Override protected void onResume() { super.onResume(); FDroidApp.checkStartTor(this, Preferences.get()); if (getIntent().hasExtra(EXTRA_VIEW_UPDATES)) { getIntent().removeExtra(EXTRA_VIEW_UPDATES); setSelectedMenuInNav(R.id.updates); } else if (getIntent().hasExtra(EXTRA_VIEW_NEARBY)) { getIntent().removeExtra(EXTRA_VIEW_NEARBY); setSelectedMenuInNav(R.id.nearby); } else if (getIntent().hasExtra(EXTRA_VIEW_SETTINGS)) { getIntent().removeExtra(EXTRA_VIEW_SETTINGS); setSelectedMenuInNav(R.id.settings); } // AppDetailsActivity and RepoDetailsActivity set different NFC actions, so reset here NfcHelper.setAndroidBeam(this, getApplication().getPackageName()); checkForAddRepoIntent(getIntent()); } @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); handleSearchOrAppViewIntent(intent); // This is called here as well as onResume(), because onNewIntent() is not called the first // time the activity is created. An alternative option to make sure that the add repo intent // is always handled is to call setIntent(intent) here. However, after this good read: // http://stackoverflow.com/a/7749347 it seems that adding a repo is not really more // important than the original intent which caused the activity to start (even though it // could technically have been an add repo intent itself). // The end result is that this method will be called twice for one add repo intent. Once // here and once in onResume(). However, the method deals with this by ensuring it only // handles the same intent once. checkForAddRepoIntent(intent); } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == REQUEST_STORAGE_ACCESS) { TreeUriScannerIntentService.onActivityResult(this, data); } } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { // NOCHECKSTYLE LineLength super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode == REQUEST_LOCATION_PERMISSIONS) { WifiStateChangeService.start(this, null); ContextCompat.startForegroundService(this, new Intent(this, SwapService.class)); } else if (requestCode == REQUEST_STORAGE_PERMISSIONS) { Toast.makeText(this, this.getString(R.string.scan_removable_storage_toast, ""), Toast.LENGTH_SHORT).show(); SDCardScannerService.scan(this); } } /** * Since any app could send this {@link Intent}, and the search terms are * fed into a SQL query, the data must be strictly sanitized to avoid * SQL injection attacks. */ private void handleSearchOrAppViewIntent(Intent intent) { if (Intent.ACTION_SEARCH.equals(intent.getAction())) { String query = intent.getStringExtra(SearchManager.QUERY); performSearch(query); return; } final Uri data = intent.getData(); if (data == null) { return; } final String scheme = data.getScheme(); final String path = data.getPath(); String packageName = null; String query = null; if (data.isHierarchical()) { final String host = data.getHost(); if (host == null) { return; } switch (host) { case "f-droid.org": case "www.f-droid.org": case "staging.f-droid.org": if (path.startsWith("/app/") || path.startsWith("/packages/") || path.matches("^/[a-z][a-z][a-zA-Z_-]*/packages/.*")) { // http://f-droid.org/app/packageName packageName = data.getLastPathSegment(); } else if (path.startsWith("/repository/browse")) { // http://f-droid.org/repository/browse?fdfilter=search+query query = data.getQueryParameter("fdfilter"); // http://f-droid.org/repository/browse?fdid=packageName packageName = data.getQueryParameter("fdid"); } else if ("/app".equals(data.getPath()) || "/packages".equals(data.getPath())) { packageName = null; } break; case "details": // market://details?id=app.id packageName = data.getQueryParameter("id"); break; case "search": // market://search?q=query query = data.getQueryParameter("q"); break; case "play.google.com": if (path.startsWith("/store/apps/details")) { // http://play.google.com/store/apps/details?id=app.id packageName = data.getQueryParameter("id"); } else if (path.startsWith("/store/search")) { // http://play.google.com/store/search?q=foo query = data.getQueryParameter("q"); } break; case "apps": case "amazon.com": case "www.amazon.com": // amzn://apps/android?p=app.id // http://amazon.com/gp/mas/dl/android?s=app.id packageName = data.getQueryParameter("p"); query = data.getQueryParameter("s"); break; } } else if ("fdroid.app".equals(scheme)) { // fdroid.app:app.id packageName = data.getSchemeSpecificPart(); } else if ("fdroid.search".equals(scheme)) { // fdroid.search:query query = data.getSchemeSpecificPart(); } if (!TextUtils.isEmpty(query)) { // an old format for querying via packageName if (query.startsWith("pname:")) { packageName = query.split(":")[1]; } // sometimes, search URLs include pub: or other things before the query string if (query.contains(":")) { query = query.split(":")[1]; } } if (!TextUtils.isEmpty(packageName)) { // sanitize packageName to be a valid Java packageName and prevent exploits packageName = packageName.replaceAll("[^A-Za-z\\d_.]", ""); Utils.debugLog(TAG, "FDroid launched via app link for '" + packageName + "'"); Intent intentToInvoke = new Intent(this, AppDetailsActivity.class); intentToInvoke.putExtra(AppDetailsActivity.EXTRA_APPID, packageName); startActivity(intentToInvoke); finish(); } else if (!TextUtils.isEmpty(query)) { Utils.debugLog(TAG, "FDroid launched via search link for '" + query + "'"); performSearch(query); } } /** * These strings might end up in a SQL query, so strip all non-alpha-num */ static String sanitizeSearchTerms(String query) { return query.replaceAll("[^\\p{L}\\d_ -]", " "); } /** * Initiates the {@link AppListActivity} with the relevant search terms passed in via the query arg. */ private void performSearch(String query) { Intent searchIntent = new Intent(this, AppListActivity.class); searchIntent.putExtra(AppListActivity.EXTRA_SEARCH_TERMS, sanitizeSearchTerms(query)); startActivity(searchIntent); } private void checkForAddRepoIntent(Intent intent) { // Don't handle the intent after coming back to this view (e.g. after hitting the back button) // http://stackoverflow.com/a/14820849 if (!intent.hasExtra(ADD_REPO_INTENT_HANDLED)) { intent.putExtra(ADD_REPO_INTENT_HANDLED, true); NewRepoConfig parser = new NewRepoConfig(this, intent); if (parser.isValidRepo()) { if (parser.isFromSwap()) { SwapWorkflowActivity.requestSwap(this, intent.getData()); } else { Intent clean = new Intent(ACTION_ADD_REPO, intent.getData(), this, ManageReposActivity.class); if (intent.hasExtra(ManageReposActivity.EXTRA_FINISH_AFTER_ADDING_REPO)) { clean.putExtra(ManageReposActivity.EXTRA_FINISH_AFTER_ADDING_REPO, intent.getBooleanExtra(ManageReposActivity.EXTRA_FINISH_AFTER_ADDING_REPO, true)); } startActivity(clean); } finish(); } else if (parser.getErrorMessage() != null) { Toast.makeText(this, parser.getErrorMessage(), Toast.LENGTH_LONG).show(); finish(); } } } private void refreshUpdatesBadge(int canUpdateCount) { if (canUpdateCount == 0) { updatesBadge.setVisible(false); updatesBadge.clearNumber(); } else { updatesBadge.setNumber(canUpdateCount); updatesBadge.setVisible(true); } } private static class NonScrollingHorizontalLayoutManager extends LinearLayoutManager { NonScrollingHorizontalLayoutManager(Context context) { super(context, LinearLayoutManager.HORIZONTAL, false); } @Override public boolean canScrollHorizontally() { return false; } @Override public boolean canScrollVertically() { return false; } } /** * There are a bunch of reasons why we would get notified about app statuses. * The ones we are interested in are those which would result in the "items requiring user interaction" * to increase or decrease: * * Change in status to: * * {@link AppUpdateStatusManager.Status#ReadyToInstall} (Causes the count to go UP by one) * * {@link AppUpdateStatusManager.Status#Installed} (Causes the count to go DOWN by one) */ private final BroadcastReceiver onUpdateableAppsChanged = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { boolean updateBadge = false; AppUpdateStatusManager manager = AppUpdateStatusManager.getInstance(context); String reason = intent.getStringExtra(AppUpdateStatusManager.EXTRA_REASON_FOR_CHANGE); switch (intent.getAction()) { // Apps which are added/removed from the list due to becoming ready to install or a repo being // disabled both cause us to increase/decrease our badge count respectively. case AppUpdateStatusManager.BROADCAST_APPSTATUS_LIST_CHANGED: if (AppUpdateStatusManager.REASON_READY_TO_INSTALL.equals(reason) || AppUpdateStatusManager.REASON_REPO_DISABLED.equals(reason)) { updateBadge = true; } break; // Apps which were previously "Ready to install" but have been removed. We need to lower our badge // count in response to this. case AppUpdateStatusManager.BROADCAST_APPSTATUS_REMOVED: AppUpdateStatus status = intent.getParcelableExtra(AppUpdateStatusManager.EXTRA_STATUS); if (status != null && status.status == AppUpdateStatusManager.Status.ReadyToInstall) { updateBadge = true; } break; } // Check if we have moved into the ReadyToInstall or Installed state. AppUpdateStatus status = manager.get( intent.getStringExtra(DownloaderService.EXTRA_CANONICAL_URL)); boolean isStatusChange = intent.getBooleanExtra(AppUpdateStatusManager.EXTRA_IS_STATUS_UPDATE, false); if (isStatusChange && status != null && (status.status == AppUpdateStatusManager.Status.ReadyToInstall || status.status == AppUpdateStatusManager.Status.Installed)) { // NOCHECKSTYLE LineLength updateBadge = true; } if (updateBadge) { int count = 0; for (AppUpdateStatus s : manager.getAll()) { if (s.status == AppUpdateStatusManager.Status.ReadyToInstall) { count++; } } refreshUpdatesBadge(count); } } }; }