From 43ccf9b48e45e73dba7bcad222b6d0bc01f7bcc8 Mon Sep 17 00:00:00 2001 From: ProgramminCat <72707293+ProgramminCat@users.noreply.github.com> Date: Tue, 2 Dec 2025 14:54:33 -0500 Subject: [PATCH] Convert MainActivity to Kotlin (#2830) Co-authored-by: Sylvia van Os --- .../protect/card_locker/MainActivity.java | 882 ----------------- .../java/protect/card_locker/MainActivity.kt | 922 ++++++++++++++++++ 2 files changed, 922 insertions(+), 882 deletions(-) delete mode 100644 app/src/main/java/protect/card_locker/MainActivity.java create mode 100644 app/src/main/java/protect/card_locker/MainActivity.kt diff --git a/app/src/main/java/protect/card_locker/MainActivity.java b/app/src/main/java/protect/card_locker/MainActivity.java deleted file mode 100644 index 777c319d2..000000000 --- a/app/src/main/java/protect/card_locker/MainActivity.java +++ /dev/null @@ -1,882 +0,0 @@ -package protect.card_locker; - -import android.app.Activity; -import android.app.SearchManager; -import android.appwidget.AppWidgetManager; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.database.CursorIndexOutOfBoundsException; -import android.database.sqlite.SQLiteDatabase; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.util.DisplayMetrics; -import android.util.Log; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.widget.CheckBox; -import android.widget.Toast; - -import androidx.activity.OnBackPressedCallback; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.view.ActionMode; -import androidx.appcompat.widget.SearchView; -import androidx.core.splashscreen.SplashScreen; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.android.material.tabs.TabLayout; - -import java.io.File; -import java.io.UnsupportedEncodingException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -import protect.card_locker.databinding.ContentMainBinding; -import protect.card_locker.databinding.MainActivityBinding; -import protect.card_locker.databinding.SortingOptionBinding; -import protect.card_locker.preferences.Settings; -import protect.card_locker.preferences.SettingsActivity; - -public class MainActivity extends CatimaAppCompatActivity implements LoyaltyCardCursorAdapter.CardAdapterListener { - private MainActivityBinding binding; - private ContentMainBinding contentMainBinding; - private static final String TAG = "Catima"; - public static final String RESTART_ACTIVITY_INTENT = "restart_activity_intent"; - - private static final int MEDIUM_SCALE_FACTOR_DIP = 460; - static final String STATE_SEARCH_QUERY = "SEARCH_QUERY"; - - private SQLiteDatabase mDatabase; - private LoyaltyCardCursorAdapter mAdapter; - private ActionMode mCurrentActionMode; - private SearchView mSearchView; - private int mLoyaltyCardCount = 0; - protected String mFilter = ""; - private String currentQuery = ""; - private String finalQuery = ""; - protected Object mGroup = null; - protected DBHelper.LoyaltyCardOrder mOrder = DBHelper.LoyaltyCardOrder.Alpha; - protected DBHelper.LoyaltyCardOrderDirection mOrderDirection = DBHelper.LoyaltyCardOrderDirection.Ascending; - protected int selectedTab = 0; - private RecyclerView mCardList; - private View mHelpSection; - private View mNoMatchingCardsText; - private View mNoGroupCardsText; - private TabLayout groupsTabLayout; - private Runnable mUpdateLoyaltyCardListRunnable; - private ActivityResultLauncher mBarcodeScannerLauncher; - private ActivityResultLauncher mSettingsLauncher; - - private ActionMode.Callback mCurrentActionModeCallback = new ActionMode.Callback() { - @Override - public boolean onCreateActionMode(ActionMode inputMode, Menu inputMenu) { - inputMode.getMenuInflater().inflate(R.menu.card_longclick_menu, inputMenu); - return true; - } - - @Override - public boolean onPrepareActionMode(ActionMode inputMode, Menu inputMenu) { - return false; - } - - @Override - public boolean onActionItemClicked(ActionMode inputMode, MenuItem inputItem) { - if (inputItem.getItemId() == R.id.action_share) { - final ImportURIHelper importURIHelper = new ImportURIHelper(MainActivity.this); - try { - importURIHelper.startShareIntent(mAdapter.getSelectedItems()); - } catch (UnsupportedEncodingException e) { - Toast.makeText(MainActivity.this, R.string.failedGeneratingShareURL, Toast.LENGTH_LONG).show(); - e.printStackTrace(); - } - inputMode.finish(); - return true; - } else if (inputItem.getItemId() == R.id.action_edit) { - if (mAdapter.getSelectedItemCount() != 1) { - throw new IllegalArgumentException("Cannot edit more than 1 card at a time"); - } - - Intent intent = new Intent(getApplicationContext(), LoyaltyCardEditActivity.class); - Bundle bundle = new Bundle(); - bundle.putInt(LoyaltyCardEditActivity.BUNDLE_ID, mAdapter.getSelectedItems().get(0).id); - bundle.putBoolean(LoyaltyCardEditActivity.BUNDLE_UPDATE, true); - intent.putExtras(bundle); - startActivity(intent); - inputMode.finish(); - return true; - } else if (inputItem.getItemId() == R.id.action_delete) { - AlertDialog.Builder builder = new MaterialAlertDialogBuilder(MainActivity.this); - // The following may seem weird, but it is necessary to give translators enough flexibility. - // For example, in Russian, Android's plural quantity "one" actually refers to "any number ending on 1 but not ending in 11". - // So while in English the extra non-plural form seems unnecessary duplication, it is necessary to give translators enough flexibility. - // In here, we use the plain string when meaning exactly 1, and otherwise use the plural forms - if (mAdapter.getSelectedItemCount() == 1) { - builder.setTitle(R.string.deleteTitle); - builder.setMessage(R.string.deleteConfirmation); - } else { - builder.setTitle(getResources().getQuantityString(R.plurals.deleteCardsTitle, mAdapter.getSelectedItemCount(), mAdapter.getSelectedItemCount())); - builder.setMessage(getResources().getQuantityString(R.plurals.deleteCardsConfirmation, mAdapter.getSelectedItemCount(), mAdapter.getSelectedItemCount())); - } - - builder.setPositiveButton(R.string.confirm, (dialog, which) -> { - for (LoyaltyCard loyaltyCard : mAdapter.getSelectedItems()) { - Log.d(TAG, "Deleting card: " + loyaltyCard.id); - - DBHelper.deleteLoyaltyCard(mDatabase, MainActivity.this, loyaltyCard.id); - - ShortcutHelper.removeShortcut(MainActivity.this, loyaltyCard.id); - } - - TabLayout.Tab tab = groupsTabLayout.getTabAt(selectedTab); - mGroup = tab != null ? tab.getTag() : null; - - updateLoyaltyCardList(true); - - dialog.dismiss(); - }); - builder.setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss()); - AlertDialog dialog = builder.create(); - dialog.show(); - - return true; - } else if (inputItem.getItemId() == R.id.action_archive) { - for (LoyaltyCard loyaltyCard : mAdapter.getSelectedItems()) { - Log.d(TAG, "Archiving card: " + loyaltyCard.id); - DBHelper.updateLoyaltyCardArchiveStatus(mDatabase, loyaltyCard.id, 1); - ShortcutHelper.removeShortcut(MainActivity.this, loyaltyCard.id); - updateLoyaltyCardList(false); - inputMode.finish(); - invalidateOptionsMenu(); - } - return true; - } else if (inputItem.getItemId() == R.id.action_unarchive) { - for (LoyaltyCard loyaltyCard : mAdapter.getSelectedItems()) { - Log.d(TAG, "Unarchiving card: " + loyaltyCard.id); - DBHelper.updateLoyaltyCardArchiveStatus(mDatabase, loyaltyCard.id, 0); - updateLoyaltyCardList(false); - inputMode.finish(); - invalidateOptionsMenu(); - } - return true; - } else if (inputItem.getItemId() == R.id.action_star) { - for (LoyaltyCard loyaltyCard : mAdapter.getSelectedItems()) { - Log.d(TAG, "Starring card: " + loyaltyCard.id); - DBHelper.updateLoyaltyCardStarStatus(mDatabase, loyaltyCard.id, 1); - updateLoyaltyCardList(false); - inputMode.finish(); - } - return true; - } else if (inputItem.getItemId() == R.id.action_unstar) { - for (LoyaltyCard loyaltyCard : mAdapter.getSelectedItems()) { - Log.d(TAG, "Unstarring card: " + loyaltyCard.id); - DBHelper.updateLoyaltyCardStarStatus(mDatabase, loyaltyCard.id, 0); - updateLoyaltyCardList(false); - inputMode.finish(); - } - return true; - } - - return false; - } - - @Override - public void onDestroyActionMode(ActionMode inputMode) { - mAdapter.clearSelections(); - mCurrentActionMode = null; - } - }; - - @Override - protected void onCreate(Bundle inputSavedInstanceState) { - SplashScreen.installSplashScreen(this); - super.onCreate(inputSavedInstanceState); - - // Delete old cache files - // These could be temporary images for the cropper, temporary images in LoyaltyCard toBundle/writeParcel/ etc. - new Thread(() -> { - long twentyFourHoursAgo = System.currentTimeMillis() - (1000 * 60 * 60 * 24); - - File[] tempFiles = getCacheDir().listFiles(); - - if (tempFiles == null) { - Log.e(TAG, "getCacheDir().listFiles() somehow returned null, this should never happen... Skipping cache cleanup..."); - return; - } - - for (File file : tempFiles) { - if (file.lastModified() < twentyFourHoursAgo) { - if (!file.delete()) { - Log.w(TAG, "Failed to delete cache file " + file.getPath()); - } - }; - } - }).start(); - - // We should extract the share intent after we called the super.onCreate as it may need to spawn a dialog window and the app needs to be initialized to not crash - extractIntentFields(getIntent()); - - binding = MainActivityBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - Utils.applyWindowInsets(binding.getRoot()); - setSupportActionBar(binding.toolbar); - groupsTabLayout = binding.groups; - contentMainBinding = ContentMainBinding.bind(binding.include.getRoot()); - - mDatabase = new DBHelper(this).getWritableDatabase(); - - mUpdateLoyaltyCardListRunnable = () -> { - updateLoyaltyCardList(false); - }; - - groupsTabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { - @Override - public void onTabSelected(TabLayout.Tab tab) { - selectedTab = tab.getPosition(); - Log.d("onTabSelected", "Tab Position " + tab.getPosition()); - mGroup = tab.getTag(); - updateLoyaltyCardList(false); - // Store active tab in Shared Preference to restore next app launch - SharedPreferences activeTabPref = getApplicationContext().getSharedPreferences( - getString(R.string.sharedpreference_active_tab), - Context.MODE_PRIVATE); - SharedPreferences.Editor activeTabPrefEditor = activeTabPref.edit(); - activeTabPrefEditor.putInt(getString(R.string.sharedpreference_active_tab), tab.getPosition()); - activeTabPrefEditor.apply(); - } - - @Override - public void onTabUnselected(TabLayout.Tab tab) { - - } - - @Override - public void onTabReselected(TabLayout.Tab tab) { - - } - }); - - mHelpSection = contentMainBinding.helpSection; - mNoMatchingCardsText = contentMainBinding.noMatchingCardsText; - mNoGroupCardsText = contentMainBinding.noGroupCardsText; - mCardList = contentMainBinding.list; - - mAdapter = new LoyaltyCardCursorAdapter(this, null, this, mUpdateLoyaltyCardListRunnable); - mCardList.setAdapter(mAdapter); - registerForContextMenu(mCardList); - - mBarcodeScannerLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { - // Exit early if the user cancelled the scan (pressed back/home) - if (result.getResultCode() != RESULT_OK) { - return; - } - - Intent editIntent = new Intent(getApplicationContext(), LoyaltyCardEditActivity.class); - editIntent.putExtras(result.getData().getExtras()); - startActivity(editIntent); - }); - - mSettingsLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { - if (result.getResultCode() == Activity.RESULT_OK) { - Intent intent = result.getData(); - if (intent != null && intent.getBooleanExtra(RESTART_ACTIVITY_INTENT, false)) { - recreate(); - } - } - }); - - getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { - @Override - public void handleOnBackPressed() { - if (mSearchView != null && !mSearchView.isIconified()) { - mSearchView.setIconified(true); - } else { - finish(); - } - } - }); - } - - @Override - protected void onResume() { - super.onResume(); - - if (mCurrentActionMode != null) { - mAdapter.clearSelections(); - mCurrentActionMode.finish(); - } - - if (mSearchView != null && !mSearchView.isIconified()) { - mFilter = mSearchView.getQuery().toString(); - } - // Start of active tab logic - updateTabGroups(groupsTabLayout); - - // Restore selected tab from Shared Preference - SharedPreferences activeTabPref = getApplicationContext().getSharedPreferences( - getString(R.string.sharedpreference_active_tab), - Context.MODE_PRIVATE); - selectedTab = activeTabPref.getInt(getString(R.string.sharedpreference_active_tab), 0); - - // Restore sort preferences from Shared Preferences - mOrder = Utils.getLoyaltyCardOrder(this); - mOrderDirection = Utils.getLoyaltyCardOrderDirection(this); - - mGroup = null; - - if (groupsTabLayout.getTabCount() != 0) { - TabLayout.Tab tab = groupsTabLayout.getTabAt(selectedTab); - if (tab == null) { - tab = groupsTabLayout.getTabAt(0); - } - - groupsTabLayout.selectTab(tab); - assert tab != null; - mGroup = tab.getTag(); - } else { - scaleScreen(); - } - - updateLoyaltyCardList(true); - // End of active tab logic - - FloatingActionButton addButton = binding.fabAdd; - - addButton.setOnClickListener(v -> { - Intent intent = new Intent(getApplicationContext(), ScanActivity.class); - Bundle bundle = new Bundle(); - if (selectedTab != 0) { - bundle.putString(LoyaltyCardEditActivity.BUNDLE_ADDGROUP, groupsTabLayout.getTabAt(selectedTab).getText().toString()); - } - intent.putExtras(bundle); - mBarcodeScannerLauncher.launch(intent); - }); - addButton.bringToFront(); - - var layoutManager = (GridLayoutManager) mCardList.getLayoutManager(); - if (layoutManager != null) { - var settings = new Settings(this); - layoutManager.setSpanCount(settings.getPreferredColumnCount()); - } - } - - private void displayCardSetupOptions(Menu menu, boolean shouldShow) { - for (int id : new int[]{R.id.action_search, R.id.action_display_options, R.id.action_sort}) { - menu.findItem(id).setVisible(shouldShow); - } - } - - private void updateLoyaltyCardCount() { - mLoyaltyCardCount = DBHelper.getLoyaltyCardCount(mDatabase); - } - - private void updateLoyaltyCardList(boolean updateCount) { - Group group = null; - if (mGroup != null) { - group = (Group) mGroup; - } - - mAdapter.swapCursor(DBHelper.getLoyaltyCardCursor(mDatabase, mFilter, group, mOrder, mOrderDirection, mAdapter.showingArchivedCards() ? DBHelper.LoyaltyCardArchiveFilter.All : DBHelper.LoyaltyCardArchiveFilter.Unarchived)); - - if (updateCount) { - updateLoyaltyCardCount(); - // Update menu icons if necessary - invalidateOptionsMenu(); - } - - if (mLoyaltyCardCount > 0) { - // We want the cardList to be visible regardless of the filtered match count - // to ensure that the noMatchingCardsText doesn't end up being shown below - // the keyboard - mHelpSection.setVisibility(View.GONE); - mNoGroupCardsText.setVisibility(View.GONE); - - if (mAdapter.getItemCount() > 0) { - mCardList.setVisibility(View.VISIBLE); - mNoMatchingCardsText.setVisibility(View.GONE); - } else { - mCardList.setVisibility(View.GONE); - if (!mFilter.isEmpty()) { - // Actual Empty Search Result - mNoMatchingCardsText.setVisibility(View.VISIBLE); - mNoGroupCardsText.setVisibility(View.GONE); - } else { - // Group Tab with no Group Cards - mNoMatchingCardsText.setVisibility(View.GONE); - mNoGroupCardsText.setVisibility(View.VISIBLE); - } - } - } else { - mCardList.setVisibility(View.GONE); - mHelpSection.setVisibility(View.VISIBLE); - - mNoMatchingCardsText.setVisibility(View.GONE); - mNoGroupCardsText.setVisibility(View.GONE); - } - - if (mCurrentActionMode != null) { - mCurrentActionMode.finish(); - } - - new ListWidget().updateAll(mAdapter.mContext); - } - - private void processParseResultList(List parseResultList, String group, boolean closeAppOnNoBarcode) { - if (parseResultList.isEmpty()) { - throw new IllegalArgumentException("parseResultList may not be empty"); - } - - Utils.makeUserChooseParseResultFromList(MainActivity.this, parseResultList, new ParseResultListDisambiguatorCallback() { - @Override - public void onUserChoseParseResult(ParseResult parseResult) { - Intent intent = new Intent(getApplicationContext(), LoyaltyCardEditActivity.class); - Bundle bundle = parseResult.toLoyaltyCardBundle(MainActivity.this); - if (group != null) { - bundle.putString(LoyaltyCardEditActivity.BUNDLE_ADDGROUP, group); - } - intent.putExtras(bundle); - startActivity(intent); - } - - @Override - public void onUserDismissedSelector() { - if (closeAppOnNoBarcode) { - finish(); - } - } - }); - } - - private void onSharedIntent(Intent intent) { - String receivedAction = intent.getAction(); - String receivedType = intent.getType(); - - if (receivedAction == null || receivedType == null) { - return; - } - - List parseResultList; - - // Check for shared text - if (receivedAction.equals(Intent.ACTION_SEND) && receivedType.equals("text/plain")) { - LoyaltyCard loyaltyCard = new LoyaltyCard(); - loyaltyCard.setCardId(intent.getStringExtra(Intent.EXTRA_TEXT)); - parseResultList = Collections.singletonList(new ParseResult(ParseResultType.BARCODE_ONLY, loyaltyCard)); - } else { - // Parse whatever file was sent, regardless of opening or sharing - Uri data; - if (receivedAction.equals(Intent.ACTION_VIEW)) { - data = intent.getData(); - } else if (receivedAction.equals(Intent.ACTION_SEND)) { - data = intent.getParcelableExtra(Intent.EXTRA_STREAM); - } else { - Log.e(TAG, "Wrong action type to parse intent"); - return; - } - - if (receivedType.startsWith("image/")) { - parseResultList = Utils.retrieveBarcodesFromImage(this, data); - } else if (receivedType.equals("application/pdf")) { - parseResultList = Utils.retrieveBarcodesFromPdf(this, data); - } else if (Arrays.asList("application/vnd.apple.pkpass", "application/vnd-com.apple.pkpass").contains(receivedType)) { - parseResultList = Utils.retrieveBarcodesFromPkPass(this, data); - } else if (receivedType.equals("application/vnd.espass-espass")) { - // FIXME: espass is not pkpass - // However, several users stated in https://github.com/CatimaLoyalty/Android/issues/2197 that the formats are extremely similar to the point they could rename an .espass file to .pkpass and have it imported - // So it makes sense to "unofficially" treat it as a PKPASS for now, even though not completely correct - parseResultList = Utils.retrieveBarcodesFromPkPass(this, data); - } else if (receivedType.equals("application/vnd.apple.pkpasses")) { - parseResultList = Utils.retrieveBarcodesFromPkPasses(this, data); - } else { - Log.e(TAG, "Wrong mime-type"); - return; - } - } - - // Give up if we should parse but there is nothing to parse - if (parseResultList == null || parseResultList.isEmpty()) { - finish(); - return; - } - - processParseResultList(parseResultList, null, true); - } - - private void extractIntentFields(Intent intent) { - onSharedIntent(intent); - } - - public void updateTabGroups(TabLayout groupsTabLayout) { - List newGroups = DBHelper.getGroups(mDatabase); - - if (newGroups.size() == 0) { - groupsTabLayout.removeAllTabs(); - groupsTabLayout.setVisibility(View.GONE); - return; - } - - groupsTabLayout.removeAllTabs(); - - TabLayout.Tab allTab = groupsTabLayout.newTab(); - allTab.setText(R.string.all); - allTab.setTag(null); - groupsTabLayout.addTab(allTab, false); - - for (Group group : newGroups) { - TabLayout.Tab tab = groupsTabLayout.newTab(); - tab.setText(group._id); - tab.setTag(group); - groupsTabLayout.addTab(tab, false); - } - - groupsTabLayout.setVisibility(View.VISIBLE); - - } - - @Override - // Saving currentQuery to finalQuery for user, this will be used to restore search history, happens when user clicks a card from list - protected void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - finalQuery = currentQuery; - // Putting the query also into outState for later use in onRestoreInstanceState when rotating screen - if (mSearchView != null) { - outState.putString(STATE_SEARCH_QUERY, finalQuery); - } - } - - @Override - // Restoring instance state when rotation of screen happens with the goal to restore search query for user - protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { - super.onRestoreInstanceState(savedInstanceState); - finalQuery = savedInstanceState.getString(STATE_SEARCH_QUERY, ""); - } - - @Override - public boolean onCreateOptionsMenu(Menu inputMenu) { - getMenuInflater().inflate(R.menu.main_menu, inputMenu); - - displayCardSetupOptions(inputMenu, mLoyaltyCardCount > 0); - - SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE); - if (searchManager != null) { - MenuItem searchMenuItem = inputMenu.findItem(R.id.action_search); - mSearchView = (SearchView) searchMenuItem.getActionView(); - mSearchView.setSearchableInfo(searchManager.getSearchableInfo(getComponentName())); - mSearchView.setSubmitButtonEnabled(false); - mSearchView.setOnCloseListener(() -> { - invalidateOptionsMenu(); - return false; - }); - - /* - * On Android 13 and later, pressing Back while the search view is open hides the keyboard - * and collapses the search view at the same time. - * This brings back the old behavior on Android 12 and lower: pressing Back once - * hides the keyboard, press again while keyboard is hidden to collapse the search view. - */ - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - searchMenuItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { - @Override - public boolean onMenuItemActionExpand(@NonNull MenuItem item) { - return true; - } - - @Override - public boolean onMenuItemActionCollapse(@NonNull MenuItem item) { - if (mSearchView.hasFocus()) { - mSearchView.clearFocus(); - return false; - } - currentQuery = ""; - mFilter = ""; - updateLoyaltyCardList(false); - return true; - } - }); - } - - mSearchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { - @Override - public boolean onQueryTextSubmit(String query) { - return false; - } - - @Override - public boolean onQueryTextChange(String newText) { - mFilter = newText; - // New logic to ensure search history after coming back from picked card - user will see the last search query - if (newText.isEmpty()) { - if(!finalQuery.isEmpty()){ - // Setting the query text for user after coming back from picked card from finalQuery - mSearchView.setQuery(finalQuery, false); - } - else if(!currentQuery.isEmpty()){ - // Else if is needed in case user deletes search - expected behaviour is to show all cards - currentQuery = ""; - mSearchView.setQuery(currentQuery, false); - } - } else { - // Setting search query each time user changes the text in search to temporary variable to be used later in finalQuery String which will be used to restore search history - currentQuery = newText; - } - TabLayout.Tab currentTab = groupsTabLayout.getTabAt(groupsTabLayout.getSelectedTabPosition()); - mGroup = currentTab != null ? currentTab.getTag() : null; - - updateLoyaltyCardList(false); - - return true; - } - }); - // Check if we came from a picked card back to search, in that case we want to show the search view with previous search query - if(!finalQuery.isEmpty()){ - // Expand the search view to show the query - searchMenuItem.expandActionView(); - // Setting the query text to empty String due to behaviour of onQueryTextChange after coming back from picked card - onQueryTextChange is called automatically without users interaction - finalQuery = ""; - mSearchView.setQuery(currentQuery, false); - } - } - - return super.onCreateOptionsMenu(inputMenu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem inputItem) { - int id = inputItem.getItemId(); - - if (id == android.R.id.home) { - getOnBackPressedDispatcher().onBackPressed(); - } - - if (id == R.id.action_display_options) { - mAdapter.showDisplayOptionsDialog(); - invalidateOptionsMenu(); - - return true; - } - - if (id == R.id.action_sort) { - AtomicInteger currentIndex = new AtomicInteger(); - List loyaltyCardOrders = Arrays.asList(DBHelper.LoyaltyCardOrder.values()); - for (int i = 0; i < loyaltyCardOrders.size(); i++) { - if (mOrder == loyaltyCardOrders.get(i)) { - currentIndex.set(i); - break; - } - } - - AlertDialog.Builder builder = new MaterialAlertDialogBuilder(MainActivity.this); - builder.setTitle(R.string.sort_by); - - SortingOptionBinding sortingOptionBinding = SortingOptionBinding - .inflate(LayoutInflater.from(MainActivity.this), null, false); - final View customLayout = sortingOptionBinding.getRoot(); - builder.setView(customLayout); - - CheckBox showReversed = sortingOptionBinding.checkBoxReverse; - - - showReversed.setChecked(mOrderDirection == DBHelper.LoyaltyCardOrderDirection.Descending); - - - builder.setSingleChoiceItems(R.array.sort_types_array, currentIndex.get(), (dialog, which) -> currentIndex.set(which)); - - builder.setPositiveButton(R.string.sort, (dialog, which) -> { - - setSort( - loyaltyCardOrders.get(currentIndex.get()), - showReversed.isChecked() ? DBHelper.LoyaltyCardOrderDirection.Descending : DBHelper.LoyaltyCardOrderDirection.Ascending - ); - - new ListWidget().updateAll(this); - - dialog.dismiss(); - }); - - builder.setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss()); - - AlertDialog dialog = builder.create(); - dialog.show(); - - return true; - } - - if (id == R.id.action_manage_groups) { - Intent i = new Intent(getApplicationContext(), ManageGroupsActivity.class); - startActivity(i); - return true; - } - - if (id == R.id.action_import_export) { - Intent i = new Intent(getApplicationContext(), ImportExportActivity.class); - startActivity(i); - return true; - } - - if (id == R.id.action_settings) { - Intent i = new Intent(getApplicationContext(), SettingsActivity.class); - mSettingsLauncher.launch(i); - return true; - } - - if (id == R.id.action_about) { - Intent i = new Intent(getApplicationContext(), AboutActivity.class); - startActivity(i); - return true; - } - - - return super.onOptionsItemSelected(inputItem); - } - - private void setSort(DBHelper.LoyaltyCardOrder order, DBHelper.LoyaltyCardOrderDirection direction) { - // Update values - mOrder = order; - mOrderDirection = direction; - - // Store in Shared Preference to restore next app launch - SharedPreferences sortPref = getApplicationContext().getSharedPreferences( - getString(R.string.sharedpreference_sort), - Context.MODE_PRIVATE); - SharedPreferences.Editor sortPrefEditor = sortPref.edit(); - sortPrefEditor.putString(getString(R.string.sharedpreference_sort_order), order.name()); - sortPrefEditor.putString(getString(R.string.sharedpreference_sort_direction), direction.name()); - sortPrefEditor.apply(); - - // Update card list - updateLoyaltyCardList(false); - } - - @Override - public void onRowLongClicked(int inputPosition) { - enableActionMode(inputPosition); - } - - private void enableActionMode(int inputPosition) { - if (mCurrentActionMode == null) { - mCurrentActionMode = startSupportActionMode(mCurrentActionModeCallback); - } - toggleSelection(inputPosition); - } - - private void scaleScreen() { - DisplayMetrics displayMetrics = new DisplayMetrics(); - getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); - int screenHeight = displayMetrics.heightPixels; - float mediumSizePx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,MEDIUM_SCALE_FACTOR_DIP,getResources().getDisplayMetrics()); - boolean shouldScaleSmaller = screenHeight < mediumSizePx; - - binding.include.welcomeIcon.setVisibility(shouldScaleSmaller ? View.GONE : View.VISIBLE); - } - - private void toggleSelection(int inputPosition) { - mAdapter.toggleSelection(inputPosition); - int count = mAdapter.getSelectedItemCount(); - - if (count == 0) { - mCurrentActionMode.finish(); - } else { - mCurrentActionMode.setTitle(getResources().getQuantityString(R.plurals.selectedCardCount, count, count)); - - MenuItem editItem = mCurrentActionMode.getMenu().findItem(R.id.action_edit); - MenuItem archiveItem = mCurrentActionMode.getMenu().findItem(R.id.action_archive); - MenuItem unarchiveItem = mCurrentActionMode.getMenu().findItem(R.id.action_unarchive); - MenuItem starItem = mCurrentActionMode.getMenu().findItem(R.id.action_star); - MenuItem unstarItem = mCurrentActionMode.getMenu().findItem(R.id.action_unstar); - - boolean hasStarred = false; - boolean hasUnstarred = false; - boolean hasArchived = false; - boolean hasUnarchived = false; - - for (LoyaltyCard loyaltyCard : mAdapter.getSelectedItems()) { - if (loyaltyCard.starStatus == 1) { - hasStarred = true; - } else { - hasUnstarred = true; - } - - if (loyaltyCard.archiveStatus == 1) { - hasArchived = true; - } else { - hasUnarchived = true; - } - - // We have all types, no need to keep checking - if (hasStarred && hasUnstarred && hasArchived && hasUnarchived) { - break; - } - } - - unarchiveItem.setVisible(hasArchived); - archiveItem.setVisible(hasUnarchived); - - if (count == 1) { - starItem.setVisible(!hasStarred); - unstarItem.setVisible(!hasUnstarred); - editItem.setVisible(true); - editItem.setEnabled(true); - } else { - starItem.setVisible(hasUnstarred); - unstarItem.setVisible(hasStarred); - - editItem.setVisible(false); - editItem.setEnabled(false); - } - - mCurrentActionMode.invalidate(); - } - } - - - @Override - public void onRowClicked(int inputPosition) { - if (mAdapter.getSelectedItemCount() > 0) { - enableActionMode(inputPosition); - } else { - // FIXME - // - // There is a really nasty edge case that can happen when someone taps a card but right - // after it swipes (very small window, hard to reproduce). The cursor gets replaced and - // may not have a card at the ID number that is returned from onRowClicked. - // - // The proper fix, obviously, would involve makes sure an onFling can't happen while a - // click is being processed. Sadly, I have not yet found a way to make that possible. - LoyaltyCard loyaltyCard; - try { - loyaltyCard = mAdapter.getCard(inputPosition); - } catch (CursorIndexOutOfBoundsException e) { - Log.w(TAG, "Prevented crash from tap + swipe on ID " + inputPosition + ": " + e); - return; - } - - Intent intent = new Intent(this, LoyaltyCardViewActivity.class); - intent.setAction(""); - final Bundle b = new Bundle(); - b.putInt(LoyaltyCardViewActivity.BUNDLE_ID, loyaltyCard.id); - - ArrayList cardList = new ArrayList<>(); - for (int i = 0; i < mAdapter.getItemCount(); i++) { - cardList.add(mAdapter.getCard(i).id); - } - - b.putIntegerArrayList(LoyaltyCardViewActivity.BUNDLE_CARDLIST, cardList); - intent.putExtras(b); - - startActivity(intent); - } - } -} diff --git a/app/src/main/java/protect/card_locker/MainActivity.kt b/app/src/main/java/protect/card_locker/MainActivity.kt new file mode 100644 index 000000000..6f544140a --- /dev/null +++ b/app/src/main/java/protect/card_locker/MainActivity.kt @@ -0,0 +1,922 @@ +package protect.card_locker + +import android.app.SearchManager +import android.content.DialogInterface +import android.content.Intent +import android.database.CursorIndexOutOfBoundsException +import android.database.sqlite.SQLiteDatabase +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.util.DisplayMetrics +import android.util.Log +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.appcompat.view.ActionMode +import androidx.appcompat.widget.SearchView +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayout.OnTabSelectedListener +import protect.card_locker.DBHelper.LoyaltyCardOrder +import protect.card_locker.DBHelper.LoyaltyCardOrderDirection +import protect.card_locker.LoyaltyCardCursorAdapter.CardAdapterListener +import protect.card_locker.databinding.ContentMainBinding +import protect.card_locker.databinding.MainActivityBinding +import protect.card_locker.databinding.SortingOptionBinding +import protect.card_locker.preferences.Settings +import protect.card_locker.preferences.SettingsActivity +import java.io.UnsupportedEncodingException +import java.util.concurrent.atomic.AtomicInteger +import androidx.core.content.edit + +class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { + private lateinit var binding: MainActivityBinding + private lateinit var contentMainBinding: ContentMainBinding + private lateinit var mDatabase: SQLiteDatabase + private lateinit var mAdapter: LoyaltyCardCursorAdapter + private var mCurrentActionMode: ActionMode? = null + private var mSearchView: SearchView? = null + private var mLoyaltyCardCount = 0 + @JvmField + var mFilter: String = "" + private var currentQuery = "" + private var finalQuery = "" + private var mGroup: Any? = null + private var mOrder: LoyaltyCardOrder = LoyaltyCardOrder.Alpha + private var mOrderDirection: LoyaltyCardOrderDirection = LoyaltyCardOrderDirection.Ascending + private var selectedTab: Int = 0 + private lateinit var groupsTabLayout: TabLayout + private lateinit var mUpdateLoyaltyCardListRunnable: Runnable + private lateinit var mBarcodeScannerLauncher: ActivityResultLauncher + private lateinit var mSettingsLauncher: ActivityResultLauncher + + private val mCurrentActionModeCallback: ActionMode.Callback = object : ActionMode.Callback { + override fun onCreateActionMode(inputMode: ActionMode, inputMenu: Menu?): Boolean { + inputMode.menuInflater.inflate(R.menu.card_longclick_menu, inputMenu) + return true + } + + override fun onPrepareActionMode(inputMode: ActionMode?, inputMenu: Menu?): Boolean { + return false + } + + override fun onActionItemClicked(inputMode: ActionMode, inputItem: MenuItem): Boolean { + when (inputItem.itemId) { + R.id.action_share -> { + try { + ImportURIHelper(this@MainActivity).startShareIntent(mAdapter.getSelectedItems()) + } catch (e: UnsupportedEncodingException) { + Toast.makeText( + this@MainActivity, + R.string.failedGeneratingShareURL, + Toast.LENGTH_LONG + ).show() + e.printStackTrace() + } + inputMode.finish() + return true + } + R.id.action_edit -> { + require(mAdapter.selectedItemCount == 1) { "Cannot edit more than 1 card at a time" } + + startActivity( + Intent(applicationContext, LoyaltyCardEditActivity::class.java).apply { + putExtras(Bundle().apply { + putInt( + LoyaltyCardEditActivity.BUNDLE_ID, + mAdapter.getSelectedItems()[0].id + ) + putBoolean(LoyaltyCardEditActivity.BUNDLE_UPDATE, true) + }) + } + ) + + inputMode.finish() + return true + } + R.id.action_delete -> { + MaterialAlertDialogBuilder(this@MainActivity).apply { + // The following may seem weird, but it is necessary to give translators enough flexibility. + // For example, in Russian, Android's plural quantity "one" actually refers to "any number ending on 1 but not ending in 11". + // So while in English the extra non-plural form seems unnecessary duplication, it is necessary to give translators enough flexibility. + // In here, we use the plain string when meaning exactly 1, and otherwise use the plural forms + if (mAdapter.selectedItemCount == 1) { + setTitle(R.string.deleteTitle) + setMessage(R.string.deleteConfirmation) + } else { + setTitle( + getResources().getQuantityString( + R.plurals.deleteCardsTitle, + mAdapter.selectedItemCount, + mAdapter.selectedItemCount + ) + ) + setMessage( + getResources().getQuantityString( + R.plurals.deleteCardsConfirmation, + mAdapter.selectedItemCount, + mAdapter.selectedItemCount + ) + ) + } + + setPositiveButton( + R.string.confirm + ) { dialog, _ -> + for (loyaltyCard in mAdapter.getSelectedItems()) { + Log.d(TAG, "Deleting card: " + loyaltyCard.id) + + DBHelper.deleteLoyaltyCard(mDatabase, this@MainActivity, loyaltyCard.id) + + ShortcutHelper.removeShortcut(this@MainActivity, loyaltyCard.id) + } + val tab = groupsTabLayout.getTabAt(selectedTab) + mGroup = tab?.tag + + updateLoyaltyCardList(true) + dialog.dismiss() + } + + setNegativeButton(R.string.cancel) { dialog, _ -> + dialog.dismiss() + } + }.create().show() + + return true + } + R.id.action_archive -> { + for (loyaltyCard in mAdapter.getSelectedItems()) { + Log.d(TAG, "Archiving card: " + loyaltyCard.id) + DBHelper.updateLoyaltyCardArchiveStatus(mDatabase, loyaltyCard.id, 1) + ShortcutHelper.removeShortcut(this@MainActivity, loyaltyCard.id) + updateLoyaltyCardList(false) + inputMode.finish() + invalidateOptionsMenu() + } + return true + } + R.id.action_unarchive -> { + for (loyaltyCard in mAdapter.getSelectedItems()) { + Log.d(TAG, "Unarchiving card: " + loyaltyCard.id) + DBHelper.updateLoyaltyCardArchiveStatus(mDatabase, loyaltyCard.id, 0) + updateLoyaltyCardList(false) + inputMode.finish() + invalidateOptionsMenu() + } + return true + } + R.id.action_star -> { + for (loyaltyCard in mAdapter.getSelectedItems()) { + Log.d(TAG, "Starring card: " + loyaltyCard.id) + DBHelper.updateLoyaltyCardStarStatus(mDatabase, loyaltyCard.id, 1) + updateLoyaltyCardList(false) + inputMode.finish() + } + return true + } + R.id.action_unstar -> { + for (loyaltyCard in mAdapter.getSelectedItems()) { + Log.d(TAG, "Unstarring card: " + loyaltyCard.id) + DBHelper.updateLoyaltyCardStarStatus(mDatabase, loyaltyCard.id, 0) + updateLoyaltyCardList(false) + inputMode.finish() + } + return true + } + } + + return false + } + + override fun onDestroyActionMode(inputMode: ActionMode?) { + mAdapter.clearSelections() + mCurrentActionMode = null + } + } + + override fun onCreate(inputSavedInstanceState: Bundle?) { + installSplashScreen() + super.onCreate(inputSavedInstanceState) + + // Delete old cache files + // These could be temporary images for the cropper, temporary images in LoyaltyCard toBundle/writeParcel/ etc. + Thread { + val twentyFourHoursAgo = System.currentTimeMillis() - (1000 * 60 * 60 * 24) + val tempFiles = cacheDir.listFiles() + + if (tempFiles == null) { + Log.e( + TAG, + "getCacheDir().listFiles() somehow returned null, this should never happen... Skipping cache cleanup..." + ) + return@Thread + } + for (file in tempFiles) { + if (file.lastModified() < twentyFourHoursAgo) { + if (!file.delete()) { + Log.w(TAG, "Failed to delete cache file " + file.path) + } + } + } + }.start() + + // We should extract the share intent after we called the super.onCreate as it may need to spawn a dialog window and the app needs to be initialized to not crash + extractIntentFields(intent) + + binding = MainActivityBinding.inflate(layoutInflater) + setContentView(binding.getRoot()) + Utils.applyWindowInsets(binding.getRoot()) + setSupportActionBar(binding.toolbar) + groupsTabLayout = binding.groups + contentMainBinding = ContentMainBinding.bind(binding.include.getRoot()) + + mDatabase = DBHelper(this).writableDatabase + + mUpdateLoyaltyCardListRunnable = Runnable { + updateLoyaltyCardList(false) + } + + groupsTabLayout.addOnTabSelectedListener(object : OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + selectedTab = tab.position + Log.d("onTabSelected", "Tab Position " + tab.position) + mGroup = tab.tag + updateLoyaltyCardList(false) + // Store active tab in Shared Preference to restore next app launch + applicationContext.getSharedPreferences( + getString(R.string.sharedpreference_active_tab), + MODE_PRIVATE + ).edit { + putInt( + getString(R.string.sharedpreference_active_tab), + tab.position + ) + } + } + + override fun onTabUnselected(tab: TabLayout.Tab?) { + } + + override fun onTabReselected(tab: TabLayout.Tab?) { + } + }) + + mAdapter = LoyaltyCardCursorAdapter(this, null, this, mUpdateLoyaltyCardListRunnable) + contentMainBinding.list.setAdapter(mAdapter) + registerForContextMenu(contentMainBinding.list) + + mBarcodeScannerLauncher = registerForActivityResult( + StartActivityForResult(), + ActivityResultCallback registerForActivityResult@{ result: ActivityResult? -> + // Exit early if the user cancelled the scan (pressed back/home) + if (result == null || result.resultCode != RESULT_OK) { + return@registerForActivityResult + } + + startActivity( + Intent(applicationContext, LoyaltyCardEditActivity::class.java).apply { + putExtras(result.data!!.extras!!) + } + ) + }) + + mSettingsLauncher = registerForActivityResult( + StartActivityForResult() + ) { result: ActivityResult? -> + if (result?.resultCode == RESULT_OK) { + val intent = result.data + if (intent != null && intent.getBooleanExtra(RESTART_ACTIVITY_INTENT, false)) { + recreate() + } + } + } + + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (mSearchView != null && !mSearchView!!.isIconified) { + mSearchView!!.isIconified = true + } else { + finish() + } + } + }) + } + + override fun onResume() { + super.onResume() + + if (mCurrentActionMode != null) { + mAdapter.clearSelections() + mCurrentActionMode!!.finish() + } + + if (mSearchView != null && !mSearchView!!.isIconified) { + mFilter = mSearchView!!.query.toString() + } + // Start of active tab logic + updateTabGroups(groupsTabLayout) + + // Restore selected tab from Shared Preference + selectedTab = applicationContext.getSharedPreferences( + getString(R.string.sharedpreference_active_tab), + MODE_PRIVATE + ).getInt(getString(R.string.sharedpreference_active_tab), 0) + + // Restore sort preferences from Shared Preferences + mOrder = Utils.getLoyaltyCardOrder(this) + mOrderDirection = Utils.getLoyaltyCardOrderDirection(this) + + mGroup = null + + if (groupsTabLayout.tabCount != 0) { + var tab = groupsTabLayout.getTabAt(selectedTab) + if (tab == null) { + tab = groupsTabLayout.getTabAt(0) + } + + groupsTabLayout.selectTab(tab) + checkNotNull(tab) + mGroup = tab.tag + } else { + scaleScreen() + } + + updateLoyaltyCardList(true) + + // End of active tab logic + + binding.fabAdd.setOnClickListener { + mBarcodeScannerLauncher.launch( + Intent(applicationContext, ScanActivity::class.java).apply { + putExtras(Bundle().apply { + if (selectedTab != 0) { + putString( + LoyaltyCardEditActivity.BUNDLE_ADDGROUP, + groupsTabLayout.getTabAt(selectedTab)!!.text.toString() + ) + } + }) + } + ) + } + binding.fabAdd.bringToFront() + + val layoutManager = contentMainBinding.list.layoutManager as GridLayoutManager? + if (layoutManager != null) { + val settings = Settings(this) + layoutManager.setSpanCount(settings.getPreferredColumnCount()) + } + } + + private fun displayCardSetupOptions(menu: Menu, shouldShow: Boolean) { + for (id in intArrayOf(R.id.action_search, R.id.action_display_options, R.id.action_sort)) { + menu.findItem(id).isVisible = shouldShow + } + } + + private fun updateLoyaltyCardCount() { + mLoyaltyCardCount = DBHelper.getLoyaltyCardCount(mDatabase) + } + + private fun updateLoyaltyCardList(updateCount: Boolean) { + var group: Group? = null + if (mGroup != null) { + group = mGroup as Group + } + + mAdapter.swapCursor( + DBHelper.getLoyaltyCardCursor( + mDatabase, + mFilter, + group, + mOrder, + mOrderDirection, + if (mAdapter.showingArchivedCards()) DBHelper.LoyaltyCardArchiveFilter.All else DBHelper.LoyaltyCardArchiveFilter.Unarchived + ) + ) + + if (updateCount) { + updateLoyaltyCardCount() + // Update menu icons if necessary + invalidateOptionsMenu() + } + + if (mLoyaltyCardCount > 0) { + // We want the cardList to be visible regardless of the filtered match count + // to ensure that the noMatchingCardsText doesn't end up being shown below + // the keyboard + contentMainBinding.helpSection.visibility = View.GONE + contentMainBinding.noGroupCardsText.visibility = View.GONE + + if (mAdapter.itemCount > 0) { + contentMainBinding.list.visibility = View.VISIBLE + contentMainBinding.noMatchingCardsText.visibility = View.GONE + } else { + contentMainBinding.list.visibility = View.GONE + if (!mFilter.isEmpty()) { + // Actual Empty Search Result + contentMainBinding.noMatchingCardsText.visibility = View.VISIBLE + contentMainBinding.noGroupCardsText.visibility = View.GONE + } else { + // Group Tab with no Group Cards + contentMainBinding.noMatchingCardsText.visibility = View.GONE + contentMainBinding.noGroupCardsText.visibility = View.VISIBLE + } + } + } else { + contentMainBinding.list.visibility = View.GONE + contentMainBinding.helpSection.visibility = View.VISIBLE + + contentMainBinding.noMatchingCardsText.visibility = View.GONE + contentMainBinding.noGroupCardsText.visibility = View.GONE + } + + if (mCurrentActionMode != null) { + mCurrentActionMode!!.finish() + } + + ListWidget().updateAll(mAdapter.mContext) + } + + private fun processParseResultList( + parseResultList: MutableList, + group: String?, + closeAppOnNoBarcode: Boolean + ) { + require(!parseResultList.isEmpty()) { "parseResultList may not be empty" } + + Utils.makeUserChooseParseResultFromList( + this@MainActivity, + parseResultList, + object : ParseResultListDisambiguatorCallback { + override fun onUserChoseParseResult(parseResult: ParseResult) { + val intent = + Intent(applicationContext, LoyaltyCardEditActivity::class.java) + val bundle = parseResult.toLoyaltyCardBundle(this@MainActivity) + if (group != null) { + bundle.putString(LoyaltyCardEditActivity.BUNDLE_ADDGROUP, group) + } + intent.putExtras(bundle) + startActivity(intent) + } + + override fun onUserDismissedSelector() { + if (closeAppOnNoBarcode) { + finish() + } + } + }) + } + + private fun onSharedIntent(intent: Intent) { + val receivedAction = intent.action + val receivedType = intent.type + + if (receivedAction == null || receivedType == null) { + return + } + + val parseResultList: MutableList? + + // Check for shared text + if (receivedAction == Intent.ACTION_SEND && receivedType == "text/plain") { + val loyaltyCard = LoyaltyCard() + loyaltyCard.setCardId(intent.getStringExtra(Intent.EXTRA_TEXT)!!) + parseResultList = mutableListOf(ParseResult(ParseResultType.BARCODE_ONLY, loyaltyCard)) + } else { + // Parse whatever file was sent, regardless of opening or sharing + val data: Uri? = when (receivedAction) { + Intent.ACTION_VIEW -> { + intent.data + } + Intent.ACTION_SEND -> { + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + else -> { + Log.e(TAG, "Wrong action type to parse intent") + return + } + } + + if (receivedType.startsWith("image/")) { + parseResultList = Utils.retrieveBarcodesFromImage(this, data) + } else if (receivedType == "application/pdf") { + parseResultList = Utils.retrieveBarcodesFromPdf(this, data) + } else if (mutableListOf( + "application/vnd.apple.pkpass", + "application/vnd-com.apple.pkpass" + ).contains(receivedType) + ) { + parseResultList = Utils.retrieveBarcodesFromPkPass(this, data) + } else if (receivedType == "application/vnd.espass-espass") { + // FIXME: espass is not pkpass + // However, several users stated in https://github.com/CatimaLoyalty/Android/issues/2197 that the formats are extremely similar to the point they could rename an .espass file to .pkpass and have it imported + // So it makes sense to "unofficially" treat it as a PKPASS for now, even though not completely correct + parseResultList = Utils.retrieveBarcodesFromPkPass(this, data) + } else if (receivedType == "application/vnd.apple.pkpasses") { + parseResultList = Utils.retrieveBarcodesFromPkPasses(this, data) + } else { + Log.e(TAG, "Wrong mime-type") + return + } + } + + // Give up if we should parse but there is nothing to parse + if (parseResultList == null || parseResultList.isEmpty()) { + finish() + return + } + + processParseResultList(parseResultList, null, true) + } + + private fun extractIntentFields(intent: Intent) { + onSharedIntent(intent) + } + + fun updateTabGroups(groupsTabLayout: TabLayout) { + val newGroups = DBHelper.getGroups(mDatabase) + + if (newGroups.isEmpty()) { + groupsTabLayout.removeAllTabs() + groupsTabLayout.visibility = View.GONE + return + } + + groupsTabLayout.removeAllTabs() + groupsTabLayout.addTab( + groupsTabLayout.newTab().apply { + setText(R.string.all) + tag = null + }, + false + ) + + for (group in newGroups) { + groupsTabLayout.addTab( + groupsTabLayout.newTab().apply { + text = group._id + tag = group + }, + false + ) + } + + groupsTabLayout.visibility = View.VISIBLE + } + + // Saving currentQuery to finalQuery for user, this will be used to restore search history, happens when user clicks a card from list + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + finalQuery = currentQuery + // Putting the query also into outState for later use in onRestoreInstanceState when rotating screen + if (mSearchView != null) { + outState.putString(STATE_SEARCH_QUERY, finalQuery) + } + } + + // Restoring instance state when rotation of screen happens with the goal to restore search query for user + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + finalQuery = savedInstanceState.getString(STATE_SEARCH_QUERY, "") + } + + override fun onCreateOptionsMenu(inputMenu: Menu): Boolean { + menuInflater.inflate(R.menu.main_menu, inputMenu) + + displayCardSetupOptions(inputMenu, mLoyaltyCardCount > 0) + + val searchManager = getSystemService(SEARCH_SERVICE) as SearchManager? + if (searchManager != null) { + val searchMenuItem = inputMenu.findItem(R.id.action_search) + mSearchView = searchMenuItem.actionView as SearchView? + mSearchView!!.setSearchableInfo(searchManager.getSearchableInfo(componentName)) + mSearchView!!.setSubmitButtonEnabled(false) + mSearchView!!.setOnCloseListener { + invalidateOptionsMenu() + false + } + + /* + * On Android 13 and later, pressing Back while the search view is open hides the keyboard + * and collapses the search view at the same time. + * This brings back the old behavior on Android 12 and lower: pressing Back once + * hides the keyboard, press again while keyboard is hidden to collapse the search view. + */ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + searchMenuItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + if (mSearchView!!.hasFocus()) { + mSearchView!!.clearFocus() + return false + } + currentQuery = "" + mFilter = "" + updateLoyaltyCardList(false) + return true + } + }) + } + + mSearchView!!.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String): Boolean { + mFilter = newText + // New logic to ensure search history after coming back from picked card - user will see the last search query + if (newText.isEmpty()) { + if (!finalQuery.isEmpty()) { + // Setting the query text for user after coming back from picked card from finalQuery + mSearchView!!.setQuery(finalQuery, false) + } else if (!currentQuery.isEmpty()) { + // Else if is needed in case user deletes search - expected behaviour is to show all cards + currentQuery = "" + mSearchView!!.setQuery(currentQuery, false) + } + } else { + // Setting search query each time user changes the text in search to temporary variable to be used later in finalQuery String which will be used to restore search history + currentQuery = newText + } + val currentTab = + groupsTabLayout.getTabAt(groupsTabLayout.selectedTabPosition) + mGroup = currentTab?.tag + + updateLoyaltyCardList(false) + + return true + } + }) + // Check if we came from a picked card back to search, in that case we want to show the search view with previous search query + if (!finalQuery.isEmpty()) { + // Expand the search view to show the query + searchMenuItem.expandActionView() + // Setting the query text to empty String due to behaviour of onQueryTextChange after coming back from picked card - onQueryTextChange is called automatically without users interaction + finalQuery = "" + mSearchView!!.setQuery(currentQuery, false) + } + } + + return super.onCreateOptionsMenu(inputMenu) + } + + override fun onOptionsItemSelected(inputItem: MenuItem): Boolean { + when (inputItem.itemId) { + android.R.id.home -> { + onBackPressedDispatcher.onBackPressed() + } + R.id.action_display_options -> { + mAdapter.showDisplayOptionsDialog() + invalidateOptionsMenu() + return true + } + R.id.action_sort -> { + val currentIndex = AtomicInteger() + val loyaltyCardOrders = listOf(*LoyaltyCardOrder.entries.toTypedArray()) + for (i in loyaltyCardOrders.indices) { + if (mOrder == loyaltyCardOrders[i]) { + currentIndex.set(i) + break + } + } + + MaterialAlertDialogBuilder(this@MainActivity).apply { + setTitle(R.string.sort_by) + + val sortingOptionBinding = SortingOptionBinding.inflate(LayoutInflater.from(this@MainActivity), null, false) + val customLayout: View = sortingOptionBinding.getRoot() + setView(customLayout) + + val showReversed = sortingOptionBinding.checkBoxReverse + + showReversed.isChecked = mOrderDirection == LoyaltyCardOrderDirection.Descending + + setSingleChoiceItems( + R.array.sort_types_array, + currentIndex.get() + ) { _: DialogInterface?, which: Int -> + currentIndex.set(which) + } + + setPositiveButton( + R.string.sort + ) { dialog, _ -> + setSort( + loyaltyCardOrders[currentIndex.get()]!!, + if (showReversed.isChecked) LoyaltyCardOrderDirection.Descending else LoyaltyCardOrderDirection.Ascending + ) + ListWidget().updateAll(this@MainActivity) + dialog?.dismiss() + } + + setNegativeButton(R.string.cancel) { dialog, _ -> + dialog.dismiss() + } + }.create().show() + + return true + } + R.id.action_manage_groups -> { + startActivity( + Intent(applicationContext, ManageGroupsActivity::class.java) + ) + return true + } + R.id.action_import_export -> { + startActivity( + Intent(applicationContext, ImportExportActivity::class.java) + ) + return true + } + R.id.action_settings -> { + mSettingsLauncher.launch( + Intent(applicationContext, SettingsActivity::class.java) + ) + return true + } + R.id.action_about -> { + startActivity( + Intent(applicationContext, AboutActivity::class.java) + ) + return true + } + } + + return super.onOptionsItemSelected(inputItem) + } + + private fun setSort(order: LoyaltyCardOrder, direction: LoyaltyCardOrderDirection) { + // Update values + mOrder = order + mOrderDirection = direction + + // Store in Shared Preference to restore next app launch + applicationContext.getSharedPreferences( + getString(R.string.sharedpreference_sort), + MODE_PRIVATE + ).edit { + putString( + getString(R.string.sharedpreference_sort_order), + order.name + ) + putString( + getString(R.string.sharedpreference_sort_direction), + direction.name + ) + } + + // Update card list + updateLoyaltyCardList(false) + } + + override fun onRowLongClicked(inputPosition: Int) { + enableActionMode(inputPosition) + } + + private fun enableActionMode(inputPosition: Int) { + if (mCurrentActionMode == null) { + mCurrentActionMode = startSupportActionMode(mCurrentActionModeCallback) + } + toggleSelection(inputPosition) + } + + private fun scaleScreen() { + val displayMetrics = DisplayMetrics() + windowManager.defaultDisplay.getMetrics(displayMetrics) + val screenHeight = displayMetrics.heightPixels + val mediumSizePx = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + MEDIUM_SCALE_FACTOR_DIP.toFloat(), + getResources().displayMetrics + ) + val shouldScaleSmaller = screenHeight < mediumSizePx + + binding.include.welcomeIcon.visibility = if (shouldScaleSmaller) View.GONE else View.VISIBLE + } + + private fun toggleSelection(inputPosition: Int) { + mAdapter.toggleSelection(inputPosition) + val count = mAdapter.selectedItemCount + + if (count == 0) { + mCurrentActionMode!!.finish() + } else { + mCurrentActionMode!!.title = getResources().getQuantityString( + R.plurals.selectedCardCount, + count, + count + ) + + val editItem = mCurrentActionMode!!.menu.findItem(R.id.action_edit) + val archiveItem = mCurrentActionMode!!.menu.findItem(R.id.action_archive) + val unarchiveItem = mCurrentActionMode!!.menu.findItem(R.id.action_unarchive) + val starItem = mCurrentActionMode!!.menu.findItem(R.id.action_star) + val unstarItem = mCurrentActionMode!!.menu.findItem(R.id.action_unstar) + + var hasStarred = false + var hasUnstarred = false + var hasArchived = false + var hasUnarchived = false + + for (loyaltyCard in mAdapter.getSelectedItems()) { + if (loyaltyCard.starStatus == 1) { + hasStarred = true + } else { + hasUnstarred = true + } + + if (loyaltyCard.archiveStatus == 1) { + hasArchived = true + } else { + hasUnarchived = true + } + + // We have all types, no need to keep checking + if (hasStarred && hasUnstarred && hasArchived && hasUnarchived) { + break + } + } + + unarchiveItem.isVisible = hasArchived + archiveItem.isVisible = hasUnarchived + + if (count == 1) { + starItem.isVisible = !hasStarred + unstarItem.isVisible = !hasUnstarred + editItem.isVisible = true + editItem.isEnabled = true + } else { + starItem.isVisible = hasUnstarred + unstarItem.isVisible = hasStarred + + editItem.isVisible = false + editItem.isEnabled = false + } + + mCurrentActionMode!!.invalidate() + } + } + + + override fun onRowClicked(inputPosition: Int) { + if (mAdapter.selectedItemCount > 0) { + enableActionMode(inputPosition) + } else { + // FIXME + // + // There is a really nasty edge case that can happen when someone taps a card but right + // after it swipes (very small window, hard to reproduce). The cursor gets replaced and + // may not have a card at the ID number that is returned from onRowClicked. + // + // The proper fix, obviously, would involve makes sure an onFling can't happen while a + // click is being processed. Sadly, I have not yet found a way to make that possible. + val loyaltyCard: LoyaltyCard + try { + loyaltyCard = mAdapter.getCard(inputPosition) + } catch (e: CursorIndexOutOfBoundsException) { + Log.w(TAG, "Prevented crash from tap + swipe on ID $inputPosition: $e") + return + } + + startActivity( + Intent(this, LoyaltyCardViewActivity::class.java).apply { + action = "" + putExtras(Bundle().apply { + putInt(LoyaltyCardViewActivity.BUNDLE_ID, loyaltyCard.id) + + val cardList = ArrayList() + for (i in 0..