/**************************************************************************************** * Copyright (c) 2009 Andrew Dubya <andrewdubya@gmail.com> * * Copyright (c) 2009 Nicolas Raoul <nicolas.raoul@gmail.com> * * Copyright (c) 2009 Edu Zamora <edu.zasu@gmail.com> * * Copyright (c) 2009 Daniel Svard <daniel.svard@gmail.com> * * Copyright (c) 2010 Norbert Nagold <norbert.nagold@gmail.com> * * Copyright (c) 2014 Timothy Rae <perceptualchaos2@gmail.com> * * * 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, see <http://www.gnu.org/licenses/>. * ****************************************************************************************/ package com.ichi2.anki; import android.Manifest; import android.content.BroadcastReceiver; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.SharedPreferences.Editor; import android.content.pm.PackageManager; import android.content.res.Resources; import android.database.SQLException; import android.graphics.PixelFormat; import android.net.Uri; import android.os.Bundle; import android.os.Message; import android.provider.Settings; import android.support.design.widget.Snackbar; import android.support.v4.app.ActivityCompat; import android.support.v4.app.DialogFragment; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentTransaction; import android.support.v4.app.ShareCompat; import android.support.v4.widget.SwipeRefreshLayout; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.text.TextUtils; import android.view.KeyEvent; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.View.OnClickListener; import android.view.Window; import android.widget.EditText; import android.widget.TextView; import android.widget.Toast; import com.afollestad.materialdialogs.MaterialDialog; import com.getbase.floatingactionbutton.FloatingActionButton; import com.getbase.floatingactionbutton.FloatingActionsMenu; import com.ichi2.anim.ActivityTransitionAnimation; import com.ichi2.anki.StudyOptionsFragment.StudyOptionsListener; import com.ichi2.anki.dialogs.AsyncDialogFragment; import com.ichi2.anki.dialogs.ConfirmationDialog; import com.ichi2.anki.dialogs.CustomStudyDialog; import com.ichi2.anki.dialogs.DatabaseErrorDialog; import com.ichi2.anki.dialogs.DeckPickerBackupNoSpaceLeftDialog; import com.ichi2.anki.dialogs.DeckPickerConfirmDeleteDeckDialog; import com.ichi2.anki.dialogs.DeckPickerContextMenu; import com.ichi2.anki.dialogs.DeckPickerExportCompleteDialog; import com.ichi2.anki.dialogs.DeckPickerNoSpaceLeftDialog; import com.ichi2.anki.dialogs.DialogHandler; import com.ichi2.anki.dialogs.ExportDialog; import com.ichi2.anki.dialogs.ImportDialog; import com.ichi2.anki.dialogs.MediaCheckDialog; import com.ichi2.anki.dialogs.SyncErrorDialog; import com.ichi2.anki.exception.ConfirmModSchemaException; import com.ichi2.anki.exception.DeckRenameException; import com.ichi2.anki.receiver.SdCardReceiver; import com.ichi2.anki.stats.AnkiStatsTaskHandler; import com.ichi2.anki.widgets.DeckAdapter; import com.ichi2.async.Connection; import com.ichi2.async.Connection.Payload; import com.ichi2.async.DeckTask; import com.ichi2.async.DeckTask.TaskData; import com.ichi2.compat.CompatHelper; import com.ichi2.libanki.Collection; import com.ichi2.libanki.Models; import com.ichi2.libanki.Sched; import com.ichi2.libanki.Utils; import com.ichi2.libanki.importer.AnkiPackageImporter; import com.ichi2.themes.StyledProgressDialog; import com.ichi2.ui.DividerItemDecoration; import com.ichi2.utils.VersionUtils; import com.ichi2.widget.WidgetStatus; import org.json.JSONException; import org.json.JSONObject; import java.io.File; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.TreeMap; import timber.log.Timber; public class DeckPicker extends NavigationDrawerActivity implements StudyOptionsListener, SyncErrorDialog.SyncErrorDialogListener, ImportDialog.ImportDialogListener, MediaCheckDialog.MediaCheckDialogListener, ExportDialog.ExportDialogListener, ActivityCompat.OnRequestPermissionsResultCallback, CustomStudyDialog.CustomStudyListener { /** * Result codes from other activities */ public static final int RESULT_MEDIA_EJECTED = 202; public static final int RESULT_DB_ERROR = 203; /** * Available options performed by other activities (request codes for onActivityResult()) */ private static final int REQUEST_STORAGE_PERMISSION = 0; private static final int REQUEST_PATH_UPDATE = 1; public static final int REPORT_FEEDBACK = 4; private static final int LOG_IN_FOR_SYNC = 6; private static final int SHOW_INFO_WELCOME = 8; private static final int SHOW_INFO_NEW_VERSION = 9; private static final int REPORT_ERROR = 10; public static final int SHOW_STUDYOPTIONS = 11; private static final int ADD_NOTE = 12; // For automatic syncing // 10 minutes in milliseconds. public static final long AUTOMATIC_SYNC_MIN_INTERVAL = 600000; private static final int SWIPE_TO_SYNC_TRIGGER_DISTANCE = 400; private MaterialDialog mProgressDialog; private View mStudyoptionsFrame; private RecyclerView mRecyclerView; private LinearLayoutManager mRecyclerViewLayoutManager; private DeckAdapter mDeckListAdapter; private FloatingActionsMenu mActionsMenu; // Note this will be null below SDK 14 private SwipeRefreshLayout mPullToSyncWrapper; private TextView mReviewSummaryTextView; private BroadcastReceiver mUnmountReceiver = null; private long mContextMenuDid; private EditText mDialogEditText; // flag asking user to do a full sync which is used in upgrade path boolean mRecommendFullSync = false; // flag keeping track of when the app has been paused private boolean mActivityPaused = false; /** * Flag to indicate whether the activity will perform a sync in its onResume. * Since syncing closes the database, this flag allows us to avoid doing any * work in onResume that might use the database and go straight to syncing. */ private boolean mSyncOnResume = false; /** * Keep track of which deck was last given focus in the deck list. If we find that this value * has changed between deck list refreshes, we need to recenter the deck list to the new current * deck. */ private long mFocusedDeck; // ---------------------------------------------------------------------------- // LISTENERS // ---------------------------------------------------------------------------- private final OnClickListener mDeckExpanderClickListener = new OnClickListener() { @Override public void onClick(View view) { Long did = (Long) view.getTag(); if (getCol().getDecks().children(did).size() > 0) { getCol().getDecks().collpase(did); updateDeckList(); dismissAllDialogFragments(); } } }; private final OnClickListener mDeckClickListener = new OnClickListener() { @Override public void onClick(View v) { long deckId = (long) v.getTag(); Timber.i("DeckPicker:: Selected deck with id %d", deckId); if (mActionsMenu != null && mActionsMenu.isExpanded()) { mActionsMenu.collapse(); } handleDeckSelection(deckId, false); if (mFragmented || !CompatHelper.isLollipop()) { // Calling notifyDataSetChanged() will update the color of the selected deck. // This interferes with the ripple effect, so we don't do it if lollipop and not tablet view mDeckListAdapter.notifyDataSetChanged(); } } }; private final OnClickListener mCountsClickListener = new OnClickListener() { @Override public void onClick(View v) { long deckId = (long) v.getTag(); Timber.i("DeckPicker:: Selected deck with id %d", deckId); if (mActionsMenu != null && mActionsMenu.isExpanded()) { mActionsMenu.collapse(); } handleDeckSelection(deckId, true); if (mFragmented || !CompatHelper.isLollipop()) { // Calling notifyDataSetChanged() will update the color of the selected deck. // This interferes with the ripple effect, so we don't do it if lollipop and not tablet view mDeckListAdapter.notifyDataSetChanged(); } } }; private final View.OnLongClickListener mDeckLongClickListener = new View.OnLongClickListener() { @Override public boolean onLongClick(View v) { long deckId = (long) v.getTag(); Timber.i("DeckPicker:: Long tapped on deck with id %d", deckId); mContextMenuDid = deckId; showDialogFragment(DeckPickerContextMenu.newInstance(deckId)); return true; } }; DeckTask.TaskListener mImportAddListener = new DeckTask.TaskListener() { @Override public void onPostExecute(DeckTask.TaskData result) { if (mProgressDialog != null && mProgressDialog.isShowing()) { mProgressDialog.dismiss(); } AnkiPackageImporter imp = (AnkiPackageImporter) result.getObjArray()[0]; showSimpleMessageDialog(TextUtils.join("\n", imp.getLog())); updateDeckList(); } @Override public void onPreExecute() { if (mProgressDialog == null || !mProgressDialog.isShowing()) { mProgressDialog = StyledProgressDialog.show(DeckPicker.this, getResources().getString(R.string.import_title), null, false); } } @Override public void onProgressUpdate(DeckTask.TaskData... values) { mProgressDialog.setContent(values[0].getString()); } @Override public void onCancelled() { } }; DeckTask.TaskListener mImportReplaceListener = new DeckTask.TaskListener() { @SuppressWarnings("unchecked") @Override public void onPostExecute(DeckTask.TaskData result) { if (mProgressDialog != null && mProgressDialog.isShowing()) { mProgressDialog.dismiss(); } Resources res = getResources(); if (result != null && result.getBoolean()) { int code = result.getInt(); if (code == -2) { // not a valid apkg file showSimpleMessageDialog(res.getString(R.string.import_log_no_apkg)); } updateDeckList(); } else { showSimpleMessageDialog(res.getString(R.string.import_log_no_apkg), true); } } @Override public void onPreExecute() { if (mProgressDialog == null || !mProgressDialog.isShowing()) { mProgressDialog = StyledProgressDialog.show(DeckPicker.this, getResources().getString(R.string.import_title), getResources().getString(R.string.import_replacing), false); } } @Override public void onProgressUpdate(DeckTask.TaskData... values) { mProgressDialog.setContent(values[0].getString()); } @Override public void onCancelled() { } }; DeckTask.TaskListener mExportListener = new DeckTask.TaskListener() { @Override public void onPreExecute() { mProgressDialog = StyledProgressDialog.show(DeckPicker.this, "", getResources().getString(R.string.export_in_progress), false); } @Override public void onPostExecute(DeckTask.TaskData result) { if (mProgressDialog != null && mProgressDialog.isShowing()) { mProgressDialog.dismiss(); } String exportPath = result.getString(); if (exportPath != null) { showAsyncDialogFragment(DeckPickerExportCompleteDialog.newInstance(exportPath)); } else { UIUtils.showThemedToast(DeckPicker.this, getResources().getString(R.string.export_unsuccessful), true); } } @Override public void onProgressUpdate(TaskData... values) { } @Override public void onCancelled() { } }; // ---------------------------------------------------------------------------- // ANDROID ACTIVITY METHODS // ---------------------------------------------------------------------------- /** Called when the activity is first created. */ @SuppressWarnings("StatementWithEmptyBody") @Override protected void onCreate(Bundle savedInstanceState) throws SQLException { Timber.d("onCreate()"); SharedPreferences preferences = AnkiDroidApp.getSharedPrefs(getBaseContext()); // Open Collection on UI thread while splash screen is showing boolean colOpen = firstCollectionOpen(); // Then set theme and content view super.onCreate(savedInstanceState); setContentView(R.layout.homescreen); View mainView = findViewById(android.R.id.content); // check, if tablet layout mStudyoptionsFrame = findViewById(R.id.studyoptions_fragment); // set protected variable from NavigationDrawerActivity mFragmented = mStudyoptionsFrame != null && mStudyoptionsFrame.getVisibility() == View.VISIBLE; registerExternalStorageListener(); // create inherited navigation drawer layout here so that it can be used by parent class initNavigationDrawer(mainView); setTitle(getResources().getString(R.string.app_name)); mRecyclerView = (RecyclerView) findViewById(R.id.files); mRecyclerView.addItemDecoration(new DividerItemDecoration(this)); // specify a LinearLayoutManager for the RecyclerView mRecyclerViewLayoutManager = new LinearLayoutManager(this); mRecyclerView.setLayoutManager(mRecyclerViewLayoutManager); // create and set an adapter for the RecyclerView mDeckListAdapter = new DeckAdapter(getLayoutInflater(), this); mDeckListAdapter.setDeckClickListener(mDeckClickListener); mDeckListAdapter.setCountsClickListener(mCountsClickListener); mDeckListAdapter.setDeckExpanderClickListener(mDeckExpanderClickListener); mDeckListAdapter.setDeckLongClickListener(mDeckLongClickListener); mRecyclerView.setAdapter(mDeckListAdapter); mPullToSyncWrapper = (SwipeRefreshLayout) findViewById(R.id.pull_to_sync_wrapper); mPullToSyncWrapper.setDistanceToTriggerSync(SWIPE_TO_SYNC_TRIGGER_DISTANCE); mPullToSyncWrapper.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() { @Override public void onRefresh() { mPullToSyncWrapper.setRefreshing(false); sync(); } }); // Setup the FloatingActionButtons mActionsMenu = (FloatingActionsMenu) findViewById(R.id.add_content_menu); if (mActionsMenu != null) { mActionsMenu.findViewById(R.id.fab_expand_menu_button).setContentDescription(getString(R.string.menu_add)); configureFloatingActionsMenu(); } else { // FloatingActionsMenu only works properly on Android 14+ so fallback on a context menu below API 14 Timber.w("Falling back on design support library FloatingActionButton"); android.support.design.widget.FloatingActionButton addButton; addButton = (android.support.design.widget.FloatingActionButton)findViewById(R.id.add_note_action); addButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { CompatHelper.getCompat().supportAddContentMenu(DeckPicker.this); } }); } mReviewSummaryTextView = (TextView) findViewById(R.id.today_stats_text_view); // Hide the fragment until the counts have been loaded so that the Toolbar fills the whole screen on tablets if (mFragmented) { mStudyoptionsFrame.setVisibility(View.GONE); } if (colOpen) { // Show any necessary dialogs (e.g. changelog, special messages, etc) showStartupScreensAndDialogs(preferences, 0); } else { // Show error dialogs if (!CollectionHelper.hasStorageAccessPermission(this)) { // This case is handled by onRequestPermissionsResult() so don't need to do anything } else if (!AnkiDroidApp.isSdCardMounted()) { // SD card not mounted onSdCardNotMounted(); } else if (!CollectionHelper.isCurrentAnkiDroidDirAccessible(this)) { // AnkiDroid directory inaccessible Intent i = CompatHelper.getCompat().getPreferenceSubscreenIntent(this, "com.ichi2.anki.prefs.advanced"); startActivityForResultWithoutAnimation(i, REQUEST_PATH_UPDATE); Toast.makeText(this, R.string.directory_inaccessible, Toast.LENGTH_LONG).show(); } else if (CollectionHelper.getInstance().exceededCursorSizeLimit(this)) { showDatabaseErrorDialog(DatabaseErrorDialog.DIALOG_CURSOR_SIZE_LIMIT_EXCEEDED); } else { showDatabaseErrorDialog(DatabaseErrorDialog.DIALOG_LOAD_FAILED); } } } /** * Try to open the Collection for the first time, and do some error handling if it wasn't successful * @return whether or not we were successful */ private boolean firstCollectionOpen() { if (CollectionHelper.hasStorageAccessPermission(this)) { // Show error dialog if collection could not be opened if (CollectionHelper.getInstance().getColSafe(this) == null) { return false; } } else { // Request storage permission if we don't have it (e.g. on Android 6.0+) ActivityCompat.requestPermissions(this, new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQUEST_STORAGE_PERMISSION); return false; } return true; } private void configureFloatingActionsMenu() { final FloatingActionButton addDeckButton = (FloatingActionButton) findViewById(R.id.add_deck_action); final FloatingActionButton addSharedButton = (FloatingActionButton) findViewById(R.id.add_shared_action); final FloatingActionButton addNoteButton = (FloatingActionButton) findViewById(R.id.add_note_action); addDeckButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View view) { if (mActionsMenu == null) { return; } mActionsMenu.collapse(); mDialogEditText = new EditText(DeckPicker.this); mDialogEditText.setSingleLine(true); // mDialogEditText.setFilters(new InputFilter[] { mDeckNameFilter }); new MaterialDialog.Builder(DeckPicker.this) .title(R.string.new_deck) .positiveText(R.string.dialog_ok) .customView(mDialogEditText, true) .callback(new MaterialDialog.ButtonCallback() { @Override public void onPositive(MaterialDialog dialog) { String deckName = mDialogEditText.getText().toString(); Timber.i("DeckPicker:: Creating new deck..."); getCol().getDecks().id(deckName, true); updateDeckList(); } }) .negativeText(R.string.dialog_cancel) .show(); } }); addSharedButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View view) { mActionsMenu.collapse(); addSharedDeck(); } }); addNoteButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View view) { mActionsMenu.collapse(); addNote(); } }); } @Override public boolean onPrepareOptionsMenu(Menu menu) { // Null check to prevent crash when col inaccessible if (CollectionHelper.getInstance().getColSafe(this) == null) { return false; } // Show / hide undo if (mFragmented || !getCol().undoAvailable()) { menu.findItem(R.id.action_undo).setVisible(false); } else { Resources res = getResources(); menu.findItem(R.id.action_undo).setVisible(true); String undo = res.getString(R.string.studyoptions_congrats_undo, getCol().undoName(res)); menu.findItem(R.id.action_undo).setTitle(undo); } return super.onPrepareOptionsMenu(menu); } @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.deck_picker, menu); boolean sdCardAvailable = AnkiDroidApp.isSdCardMounted(); menu.findItem(R.id.action_sync).setEnabled(sdCardAvailable); menu.findItem(R.id.action_new_filtered_deck).setEnabled(sdCardAvailable); menu.findItem(R.id.action_check_database).setEnabled(sdCardAvailable); menu.findItem(R.id.action_check_media).setEnabled(sdCardAvailable); menu.findItem(R.id.action_empty_cards).setEnabled(sdCardAvailable); // Hide import, export, and restore backup on ChromeOS as users // don't have access to the file system. if (CompatHelper.isChromebook()) { menu.findItem(R.id.action_restore_backup).setVisible(false); menu.findItem(R.id.action_import).setVisible(false); menu.findItem(R.id.action_export).setVisible(false); } return super.onCreateOptionsMenu(menu); } @Override public boolean onOptionsItemSelected(MenuItem item) { Resources res = getResources(); if (getDrawerToggle().onOptionsItemSelected(item)) { return true; } switch (item.getItemId()) { case R.id.action_undo: Timber.i("DeckPicker:: Undo button pressed"); undo(); return true; case R.id.action_sync: Timber.i("DeckPicker:: Sync button pressed"); sync(); return true; case R.id.action_import: Timber.i("DeckPicker:: Import button pressed"); showImportDialog(ImportDialog.DIALOG_IMPORT_HINT); return true; case R.id.action_new_filtered_deck: Timber.i("DeckPicker:: New filtered deck button pressed"); mDialogEditText = new EditText(DeckPicker.this); ArrayList<String> names = getCol().getDecks().allNames(); int n = 1; String name = String.format(Locale.getDefault(), "%s %d", res.getString(R.string.filtered_deck_name), n); while (names.contains(name)) { n++; name = String.format(Locale.getDefault(), "%s %d", res.getString(R.string.filtered_deck_name), n); } mDialogEditText.setText(name); // mDialogEditText.setFilters(new InputFilter[] { mDeckNameFilter }); new MaterialDialog.Builder(DeckPicker.this) .title(res.getString(R.string.new_deck)) .customView(mDialogEditText, true) .positiveText(res.getString(R.string.create)) .negativeText(res.getString(R.string.dialog_cancel)) .callback(new MaterialDialog.ButtonCallback() { @Override public void onPositive(MaterialDialog dialog) { String filteredDeckName = mDialogEditText.getText().toString(); Timber.i("DeckPicker:: Creating filtered deck..."); getCol().getDecks().newDyn(filteredDeckName); openStudyOptions(true); } }) .show(); return true; case R.id.action_check_database: Timber.i("DeckPicker:: Check database button pressed"); showDatabaseErrorDialog(DatabaseErrorDialog.DIALOG_CONFIRM_DATABASE_CHECK); return true; case R.id.action_check_media: Timber.i("DeckPicker:: Check media button pressed"); showMediaCheckDialog(MediaCheckDialog.DIALOG_CONFIRM_MEDIA_CHECK); return true; case R.id.action_empty_cards: Timber.i("DeckPicker:: Empty cards button pressed"); handleEmptyCards(); return true; case R.id.action_model_browser_open: Timber.i("DeckPicker:: Model browser button pressed"); Intent noteTypeBrowser = new Intent(this, ModelBrowser.class); startActivityForResultWithAnimation(noteTypeBrowser, 0, ActivityTransitionAnimation.LEFT); return true; case R.id.action_restore_backup: Timber.i("DeckPicker:: Restore from backup button pressed"); showDatabaseErrorDialog(DatabaseErrorDialog.DIALOG_CONFIRM_RESTORE_BACKUP); return true; case R.id.action_export: Timber.i("DeckPicker:: Export collection button pressed"); String msg = getResources().getString(R.string.confirm_apkg_export); showDialogFragment(ExportDialog.newInstance(msg)); return true; default: return super.onOptionsItemSelected(item); } } @Override protected void onActivityResult(int requestCode, int resultCode, Intent intent) { super.onActivityResult(requestCode, resultCode, intent); if (resultCode == RESULT_MEDIA_EJECTED) { onSdCardNotMounted(); return; } else if (resultCode == RESULT_DB_ERROR) { handleDbError(); return; } if (requestCode == REPORT_ERROR) { showStartupScreensAndDialogs(AnkiDroidApp.getSharedPrefs(getBaseContext()), 4); } else if (requestCode == SHOW_INFO_WELCOME || requestCode == SHOW_INFO_NEW_VERSION) { if (resultCode == RESULT_OK) { showStartupScreensAndDialogs(AnkiDroidApp.getSharedPrefs(getBaseContext()), requestCode == SHOW_INFO_WELCOME ? 2 : 3); } else { finishWithAnimation(); } } else if (requestCode == LOG_IN_FOR_SYNC && resultCode == RESULT_OK) { mSyncOnResume = true; } else if ((requestCode == REQUEST_REVIEW || requestCode == SHOW_STUDYOPTIONS) && resultCode == Reviewer.RESULT_NO_MORE_CARDS) { // Show a message when reviewing has finished int[] studyOptionsCounts = getCol().getSched().counts(); if (studyOptionsCounts[0] + studyOptionsCounts[1] + studyOptionsCounts[2] == 0) { UIUtils.showSimpleSnackbar(this, R.string.studyoptions_congrats_finished, false); } else { UIUtils.showSimpleSnackbar(this, R.string.studyoptions_no_cards_due, false); } } else if (requestCode == REQUEST_BROWSE_CARDS) { // Store the selected deck after opening browser if (intent != null && intent.getBooleanExtra("allDecksSelected", false)) { AnkiDroidApp.getSharedPrefs(this).edit().putLong("browserDeckIdFromDeckPicker", -1L).apply(); } else { long selectedDeck = getCol().getDecks().selected(); AnkiDroidApp.getSharedPrefs(this).edit().putLong("browserDeckIdFromDeckPicker", selectedDeck).apply(); } } else if (requestCode == REQUEST_PATH_UPDATE) { // The collection path was inaccessible on startup so just close the activity and let user restart finishWithoutAnimation(); } } public void onRequestPermissionsResult (int requestCode, String[] permissions, int[] grantResults) { if (requestCode == REQUEST_STORAGE_PERMISSION && permissions.length == 1) { if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { showStartupScreensAndDialogs(AnkiDroidApp.getSharedPrefs(this), 0); } else { // User denied access to the SD card so show error toast and finish activity Toast.makeText(this, R.string.directory_inaccessible, Toast.LENGTH_LONG).show(); finishWithoutAnimation(); // Open the Android settings page for our app so that the user can grant the missing permission Intent intent = new Intent(); intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); Uri uri = Uri.fromParts("package", getPackageName(), null); intent.setData(uri); startActivityWithoutAnimation(intent); } } } @Override protected void onResume() { Timber.d("onResume()"); super.onResume(); mActivityPaused = false; if (mSyncOnResume) { sync(); mSyncOnResume = false; } else if (colIsOpen()) { selectNavigationItem(R.id.nav_decks); updateDeckList(); setTitle(getResources().getString(R.string.app_name)); } } @Override public void onSaveInstanceState(Bundle savedInstanceState) { super.onSaveInstanceState(savedInstanceState); savedInstanceState.putLong("mContextMenuDid", mContextMenuDid); } @Override public void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); mContextMenuDid = savedInstanceState.getLong("mContextMenuDid"); } @Override protected void onPause() { Timber.d("onPause()"); mActivityPaused = true; super.onPause(); } @Override protected void onStop() { Timber.d("onStop()"); super.onStop(); if (colIsOpen()) { WidgetStatus.update(this); UIUtils.saveCollectionInBackground(this); } } @Override protected void onDestroy() { super.onDestroy(); if (mUnmountReceiver != null) { unregisterReceiver(mUnmountReceiver); } if (mProgressDialog != null && mProgressDialog.isShowing()) { mProgressDialog.dismiss(); } Timber.d("onDestroy()"); } private void automaticSync() { SharedPreferences preferences = AnkiDroidApp.getSharedPrefs(getBaseContext()); // Check whether the option is selected, the user is signed in and last sync was AUTOMATIC_SYNC_TIME ago // (currently 10 minutes) String hkey = preferences.getString("hkey", ""); long lastSyncTime = preferences.getLong("lastSyncTime", 0); if (hkey.length() != 0 && preferences.getBoolean("automaticSyncMode", false) && Connection.isOnline() && Utils.intNow(1000) - lastSyncTime > AUTOMATIC_SYNC_MIN_INTERVAL) { sync(); } } @Override public void onBackPressed() { if (isDrawerOpen()) { super.onBackPressed(); } else { Timber.i("Back key pressed"); if (mActionsMenu != null && mActionsMenu.isExpanded()) { mActionsMenu.collapse(); } else { automaticSync(); finishWithAnimation(); } } } private void finishWithAnimation() { super.finishWithAnimation(ActivityTransitionAnimation.DOWN); } // ---------------------------------------------------------------------------- // CUSTOM METHODS // ---------------------------------------------------------------------------- /** * Perform the following tasks: * Automatic backup * loadStudyOptionsFragment() if tablet * Automatic sync */ private void onFinishedStartup() { // create backup in background if needed BackupManager.performBackupInBackground(getCol().getPath()); // Force a full sync if flag was set in upgrade path, asking the user to confirm if necessary if (mRecommendFullSync) { mRecommendFullSync = false; try { getCol().modSchema(); } catch (ConfirmModSchemaException e) { // If libanki determines it's necessary to confirm the full sync then show a confirmation dialog // We have to show the dialog via the DialogHandler since this method is called via a Loader Resources res = getResources(); Message handlerMessage = Message.obtain(); handlerMessage.what = DialogHandler.MSG_SHOW_FORCE_FULL_SYNC_DIALOG; Bundle handlerMessageData = new Bundle(); handlerMessageData.putString("message", res.getString(R.string.full_sync_confirmation_upgrade) + "\n\n" + res.getString(R.string.full_sync_confirmation)); handlerMessage.setData(handlerMessageData); getDialogHandler().sendMessage(handlerMessage); } } // Open StudyOptionsFragment if in fragmented mode if (mFragmented) { loadStudyOptionsFragment(false); } automaticSync(); } @Override protected void onCollectionLoadError() { getDialogHandler().sendEmptyMessage(DialogHandler.MSG_SHOW_COLLECTION_LOADING_ERROR_DIALOG); } public void addNote() { Intent intent = new Intent(DeckPicker.this, NoteEditor.class); intent.putExtra(NoteEditor.EXTRA_CALLER, NoteEditor.CALLER_DECKPICKER); startActivityForResultWithAnimation(intent, ADD_NOTE, ActivityTransitionAnimation.LEFT); } private void showStartupScreensAndDialogs(SharedPreferences preferences, int skip) { if (!BackupManager.enoughDiscSpace(CollectionHelper.getCurrentAnkiDroidDirectory(this))) { // Not enough space to do backup showDialogFragment(DeckPickerNoSpaceLeftDialog.newInstance()); } else if (preferences.getBoolean("noSpaceLeft", false)) { // No space left showDialogFragment(DeckPickerBackupNoSpaceLeftDialog.newInstance()); preferences.edit().remove("noSpaceLeft").commit(); } else if (preferences.getString("lastVersion", "").equals("")) { // Fresh install preferences.edit().putString("lastVersion", VersionUtils.getPkgVersionName()).commit(); onFinishedStartup(); } else if (skip < 2 && !preferences.getString("lastVersion", "").equals(VersionUtils.getPkgVersionName())) { // AnkiDroid is being updated and a collection already exists. We check if we are upgrading // to a version that contains additions to the database integrity check routine that we would // like to run on all collections. A missing version number is assumed to be a fresh // installation of AnkiDroid and we don't run the check. int current = VersionUtils.getPkgVersionCode(); int previous; if (!preferences.contains("lastUpgradeVersion")) { // Fresh install previous = current; } else { try { previous = preferences.getInt("lastUpgradeVersion", current); } catch (ClassCastException e) { // Previous versions stored this as a string. String s = preferences.getString("lastUpgradeVersion", ""); // The last version of AnkiDroid that stored this as a string was 2.0.2. // We manually set the version here, but anything older will force a DB // check. if (s.equals("2.0.2")) { previous = 40; } else { previous = 0; } } } preferences.edit().putInt("lastUpgradeVersion", current).commit(); preferences.edit().remove("sentExceptionReports").commit(); // clear cache of sent exception reports // Delete the media database made by any version before 2.3 beta due to upgrade errors. // It is rebuilt on the next sync or media check if (previous < 20300200) { File mediaDb = new File(CollectionHelper.getCurrentAnkiDroidDirectory(this), "collection.media.ad.db2"); if (mediaDb.exists()) { mediaDb.delete(); } } // Recommend the user to do a full-sync if they're upgrading from before 2.3.1beta8 if (previous < 20301208) { mRecommendFullSync = true; } // Fix "font-family" definition in templates created by AnkiDroid before 2.6alhpa23 if (previous < 20600123) { try { Models models = getCol().getModels(); for (JSONObject m : models.all()) { String css = m.getString("css"); if (css.contains("font-familiy")) { m.put("css", css.replace("font-familiy", "font-family")); models.save(m); } } models.flush(); } catch (JSONException e) { Timber.e(e, "Failed to upgrade css definitions."); } } // Check if preference upgrade or database check required, otherwise go to new feature screen int upgradePrefsVersion = AnkiDroidApp.CHECK_PREFERENCES_AT_VERSION; int upgradeDbVersion = AnkiDroidApp.CHECK_DB_AT_VERSION; if (previous < upgradeDbVersion || previous < upgradePrefsVersion) { if (previous < upgradePrefsVersion && current >= upgradePrefsVersion) { Timber.d("Upgrading preferences"); CompatHelper.removeHiddenPreferences(this.getApplicationContext()); upgradePreferences(previous); } // Integrity check loads asynchronously and then restart deckpicker when finished if (previous < upgradeDbVersion && current >= upgradeDbVersion) { integrityCheck(); } else if (previous < upgradePrefsVersion && current >= upgradePrefsVersion) { // If integrityCheck() doesn't occur, but we did update preferences we should restart DeckPicker to // proceed restartActivity(); } } else { // If no changes are required we go to the new features activity // There the "lastVersion" is set, so that this code is not reached again if (VersionUtils.isReleaseVersion()) { Intent infoIntent = new Intent(this, Info.class); infoIntent.putExtra(Info.TYPE_EXTRA, Info.TYPE_NEW_VERSION); if (skip != 0) { startActivityForResultWithAnimation(infoIntent, SHOW_INFO_NEW_VERSION, ActivityTransitionAnimation.LEFT); } else { startActivityForResultWithoutAnimation(infoIntent, SHOW_INFO_NEW_VERSION); } } else { // Don't show new features dialog for development builds preferences.edit().putString("lastVersion", VersionUtils.getPkgVersionName()).apply(); String ver = getResources().getString(R.string.updated_version, VersionUtils.getPkgVersionName()); UIUtils.showSnackbar(this, ver, true, -1, null, findViewById(R.id.root_layout), null); showStartupScreensAndDialogs(preferences, 2); } } } else { // This is the main call when there is nothing special required onFinishedStartup(); } } private void upgradePreferences(int previousVersionCode) { SharedPreferences preferences = AnkiDroidApp.getSharedPrefs(getBaseContext()); // clear all prefs if super old version to prevent any errors if (previousVersionCode < 20300130) { preferences.edit().clear().commit(); } // when upgrading from before 2.5alpha35 if (previousVersionCode < 20500135) { // Card zooming behaviour was changed the preferences renamed int oldCardZoom = preferences.getInt("relativeDisplayFontSize", 100); int oldImageZoom = preferences.getInt("relativeImageSize", 100); preferences.edit().putInt("cardZoom", oldCardZoom).commit(); preferences.edit().putInt("imageZoom", oldImageZoom).commit(); if (!preferences.getBoolean("useBackup", true)) { preferences.edit().putInt("backupMax", 0).commit(); } preferences.edit().remove("useBackup").commit(); preferences.edit().remove("intentAdditionInstantAdd").commit(); } if (preferences.contains("fullscreenReview")) { // clear fullscreen flag as we use a integer try { boolean old = preferences.getBoolean("fullscreenReview", false); preferences.edit().putString("fullscreenMode", old ? "1": "0").commit(); } catch (ClassCastException e) { // TODO: can remove this catch as it was only here to fix an error in the betas preferences.edit().remove("fullscreenMode").commit(); } preferences.edit().remove("fullscreenReview").commit(); } } private void undo() { String undoReviewString = getResources().getString(R.string.undo_action_review); final boolean isReview = undoReviewString.equals(getCol().undoName(getResources())); DeckTask.launchDeckTask(DeckTask.TASK_TYPE_UNDO, new DeckTask.TaskListener() { @Override public void onCancelled() { hideProgressBar(); } @Override public void onPreExecute() { showProgressBar(); } @Override public void onPostExecute(TaskData result) { hideProgressBar(); if (isReview) { openReviewer(); } } @Override public void onProgressUpdate(TaskData... values) { } }); } // Show dialogs to deal with database loading issues etc public void showDatabaseErrorDialog(int id) { AsyncDialogFragment newFragment = DatabaseErrorDialog.newInstance(id); showAsyncDialogFragment(newFragment); } @Override public void showMediaCheckDialog(int id) { showAsyncDialogFragment(MediaCheckDialog.newInstance(id)); } @Override public void showMediaCheckDialog(int id, List<List<String>> checkList) { showAsyncDialogFragment(MediaCheckDialog.newInstance(id, checkList)); } /** * Show a specific sync error dialog * @param id id of dialog to show */ @Override public void showSyncErrorDialog(int id) { showSyncErrorDialog(id, ""); } /** * Show a specific sync error dialog * @param id id of dialog to show * @param message text to show */ @Override public void showSyncErrorDialog(int id, String message) { AsyncDialogFragment newFragment = SyncErrorDialog.newInstance(id, message); showAsyncDialogFragment(newFragment); } /** * Show simple error dialog with just the message and OK button. Reload the activity when dialog closed. * @param message */ private void showSyncErrorMessage(String message) { String title = getResources().getString(R.string.sync_error); showSimpleMessageDialog(title, message, true); } /** * Show a simple snackbar message or notification if the activity is not in foreground * @param messageResource String resource for message */ private void showSyncLogMessage(int messageResource, String syncMessage) { if (mActivityPaused) { Resources res = AnkiDroidApp.getAppResources(); showSimpleNotification(res.getString(R.string.app_name), res.getString(messageResource)); } else { if (syncMessage == null || syncMessage.length() == 0) { UIUtils.showSimpleSnackbar(this, messageResource, false); } else { Resources res = AnkiDroidApp.getAppResources(); showSimpleMessageDialog(res.getString(messageResource), syncMessage, false); } } } @Override public void showImportDialog(int id) { showImportDialog(id, ""); } @Override public void showImportDialog(int id, String message) { DialogFragment newFragment = ImportDialog.newInstance(id, message); showDialogFragment(newFragment); } public void onSdCardNotMounted() { UIUtils.showThemedToast(this, getResources().getString(R.string.sd_card_not_mounted), false); finishWithoutAnimation(); } // Callback method to submit error report public void sendErrorReport() { AnkiDroidApp.sendExceptionReport(new RuntimeException(), "DeckPicker.sendErrorReport"); } // Callback method to handle repairing deck public void repairDeck() { DeckTask.launchDeckTask(DeckTask.TASK_TYPE_REPAIR_DECK, new DeckTask.TaskListener() { @Override public void onPreExecute() { mProgressDialog = StyledProgressDialog.show(DeckPicker.this, "", getResources().getString(R.string.backup_repair_deck_progress), false); } @Override public void onPostExecute(DeckTask.TaskData result) { if (mProgressDialog != null && mProgressDialog.isShowing()) { mProgressDialog.dismiss(); } if (result == null || !result.getBoolean()) { UIUtils.showThemedToast(DeckPicker.this, getResources().getString(R.string.deck_repair_error), true); onCollectionLoadError(); } } @Override public void onProgressUpdate(TaskData... values) { } @Override public void onCancelled() { } }); } // Callback method to handle database integrity check public void integrityCheck() { DeckTask.launchDeckTask(DeckTask.TASK_TYPE_CHECK_DATABASE, new DeckTask.TaskListener() { @Override public void onPreExecute() { mProgressDialog = StyledProgressDialog.show(DeckPicker.this, "", getResources().getString(R.string.check_db_message), false); } @Override public void onPostExecute(TaskData result) { if (mProgressDialog != null && mProgressDialog.isShowing()) { mProgressDialog.dismiss(); } if (result != null && result.getBoolean()) { String msg = ""; long shrunk = Math.round(result.getLong() / 1024.0); if (shrunk > 0.0) { msg = String.format(Locale.getDefault(), getResources().getString(R.string.check_db_acknowledge_shrunk), (int) shrunk); } else { msg = getResources().getString(R.string.check_db_acknowledge); } // Show result of database check and restart the app showSimpleMessageDialog(msg, true); } else { handleDbError(); } } @Override public void onProgressUpdate(TaskData... values) { } @Override public void onCancelled() { } }); } @Override public void mediaCheck() { DeckTask.launchDeckTask(DeckTask.TASK_TYPE_CHECK_MEDIA, new DeckTask.TaskListener() { @Override public void onPreExecute() { mProgressDialog = StyledProgressDialog.show(DeckPicker.this, "", getResources().getString(R.string.check_media_message), false); } @Override public void onPostExecute(TaskData result) { if (mProgressDialog != null && mProgressDialog.isShowing()) { mProgressDialog.dismiss(); } if (result != null && result.getBoolean()) { @SuppressWarnings("unchecked") List<List<String>> checkList = (List<List<String>>) result.getObjArray()[0]; showMediaCheckDialog(MediaCheckDialog.DIALOG_MEDIA_CHECK_RESULTS, checkList); } else { showSimpleMessageDialog(getResources().getString(R.string.check_media_failed)); } } @Override public void onProgressUpdate(TaskData... values) { } @Override public void onCancelled() { } }); } @Override public void deleteUnused(List<String> unused) { com.ichi2.libanki.Media m = getCol().getMedia(); for (String fname : unused) { m.removeFile(fname); } showSimpleMessageDialog(String.format(getResources().getString(R.string.check_media_deleted), unused.size())); } public void exit() { CollectionHelper.getInstance().closeCollection(false); finishWithoutAnimation(); System.exit(0); } public void handleDbError() { showDatabaseErrorDialog(DatabaseErrorDialog.DIALOG_LOAD_FAILED); } public void restoreFromBackup(String path) { importReplace(path); } // Helper function to check if there are any saved stacktraces public boolean hasErrorFiles() { for (String file : this.fileList()) { if (file.endsWith(".stacktrace")) { return true; } } return false; } // Sync with Anki Web @Override public void sync() { sync(null); } /** * The mother of all syncing attempts. This might be called from sync() as first attempt to sync a collection OR * from the mSyncConflictResolutionListener if the first attempt determines that a full-sync is required. * * @param syncConflictResolution Either "upload" or "download", depending on the user's choice. */ @Override public void sync(String syncConflictResolution) { SharedPreferences preferences = AnkiDroidApp.getSharedPrefs(getBaseContext()); String hkey = preferences.getString("hkey", ""); if (hkey.length() == 0) { mPullToSyncWrapper.setRefreshing(false); showSyncErrorDialog(SyncErrorDialog.DIALOG_USER_NOT_LOGGED_IN_SYNC); } else { Connection.sync(mSyncListener, new Connection.Payload(new Object[] { hkey, preferences.getBoolean("syncFetchesMedia", true), syncConflictResolution })); } } private Connection.TaskListener mSyncListener = new Connection.CancellableTaskListener() { String currentMessage; long countUp; long countDown; @Override public void onDisconnected() { showSyncLogMessage(R.string.youre_offline, ""); } @Override public void onCancelled() { mProgressDialog.dismiss(); showSyncLogMessage(R.string.sync_cancelled, ""); // update deck list in case sync was cancelled during media sync and main sync was actually successful updateDeckList(); } @Override public void onPreExecute() { countUp = 0; countDown = 0; // Store the current time so that we don't bother the user with a sync prompt for another 10 minutes // Note: getLs() in Libanki doesn't take into account the case when no changes were found, or sync cancelled SharedPreferences preferences = AnkiDroidApp.getSharedPrefs(getBaseContext()); final long syncStartTime = System.currentTimeMillis(); preferences.edit().putLong("lastSyncTime", syncStartTime).apply(); if (mProgressDialog == null || !mProgressDialog.isShowing()) { mProgressDialog = StyledProgressDialog .show(DeckPicker.this, getResources().getString(R.string.sync_title), getResources().getString(R.string.sync_title) + "\n" + getResources().getString(R.string.sync_up_down_size, countUp, countDown), false); // Override the back key so that the user can cancel a sync which is in progress mProgressDialog.setOnKeyListener(new DialogInterface.OnKeyListener() { @Override public boolean onKey(DialogInterface dialog, int keyCode, KeyEvent event) { // Make sure our method doesn't get called twice if (event.getAction()!=KeyEvent.ACTION_DOWN) { return true; } if (keyCode == KeyEvent.KEYCODE_BACK && Connection.isCancellable() && !Connection.getIsCancelled()) { // If less than 2s has elapsed since sync started then don't ask for confirmation if (System.currentTimeMillis() - syncStartTime < 2000) { Connection.cancel(); mProgressDialog.setContent(R.string.sync_cancel_message); return true; } // Show confirmation dialog to check if the user wants to cancel the sync MaterialDialog.Builder builder = new MaterialDialog.Builder(mProgressDialog.getContext()); builder.content(R.string.cancel_sync_confirm) .cancelable(false) .positiveText(R.string.dialog_ok) .negativeText(R.string.continue_sync) .callback(new MaterialDialog.ButtonCallback() { @Override public void onPositive(MaterialDialog dialog) { mProgressDialog.setContent(R.string.sync_cancel_message); Connection.cancel(); } }); builder.show(); return true; } else { return false; } } }); } } @Override public void onProgressUpdate(Object... values) { Resources res = getResources(); if (values[0] instanceof Boolean) { // This is the part Download missing media of syncing int total = (Integer) values[1]; int done = (Integer) values[2]; values[0] = (values[3]); values[1] = res.getString(R.string.sync_downloading_media, done, total); } else if (values[0] instanceof Integer) { int id = (Integer) values[0]; if (id != 0) { currentMessage = res.getString(id); } if (values.length >= 3) { countUp = (Long) values[1]; countDown = (Long) values[2]; } } else if (values[0] instanceof String) { currentMessage = (String) values[0]; if (values.length >= 3) { countUp = (Long) values[1]; countDown = (Long) values[2]; } } if (mProgressDialog != null && mProgressDialog.isShowing()) { // mProgressDialog.setTitle((String) values[0]); mProgressDialog.setContent(currentMessage + "\n" + res .getString(R.string.sync_up_down_size, countUp / 1024, countDown / 1024)); } } @SuppressWarnings("unchecked") @Override public void onPostExecute(Payload data) { mPullToSyncWrapper.setRefreshing(false); String dialogMessage = ""; String syncMessage = ""; Timber.d("Sync Listener onPostExecute()"); Resources res = getResources(); try { if (mProgressDialog != null && mProgressDialog.isShowing()) { mProgressDialog.dismiss(); } } catch (IllegalArgumentException e) { Timber.e(e, "Could not dismiss mProgressDialog. The Activity must have been destroyed while the AsyncTask was running"); AnkiDroidApp.sendExceptionReport(e, "DeckPicker.onPostExecute", "Could not dismiss mProgressDialog"); } syncMessage = data.message; if (!data.success) { Object[] result = (Object[]) data.result; if (result[0] instanceof String) { String resultType = (String) result[0]; if (resultType.equals("badAuth")) { // delete old auth information SharedPreferences preferences = AnkiDroidApp.getSharedPrefs(getBaseContext()); Editor editor = preferences.edit(); editor.putString("username", ""); editor.putString("hkey", ""); editor.commit(); // then show not logged in dialog showSyncErrorDialog(SyncErrorDialog.DIALOG_USER_NOT_LOGGED_IN_SYNC); } else if (resultType.equals("noChanges")) { // show no changes message, use false flag so we don't show "sync error" as the Dialog title showSyncLogMessage(R.string.sync_no_changes_message, ""); } else if (resultType.equals("clockOff")) { long diff = (Long) result[1]; if (diff >= 86100) { // The difference if more than a day minus 5 minutes acceptable by ankiweb error dialogMessage = res.getString(R.string.sync_log_clocks_unsynchronized, diff, res.getString(R.string.sync_log_clocks_unsynchronized_date)); } else if (Math.abs((diff % 3600.0) - 1800.0) >= 1500.0) { // The difference would be within limit if we adjusted the time by few hours // It doesn't work for all timezones, but it covers most and it's a guess anyway dialogMessage = res.getString(R.string.sync_log_clocks_unsynchronized, diff, res.getString(R.string.sync_log_clocks_unsynchronized_tz)); } else { dialogMessage = res.getString(R.string.sync_log_clocks_unsynchronized, diff, ""); } showSyncErrorMessage(joinSyncMessages(dialogMessage, syncMessage)); } else if (resultType.equals("fullSync")) { if (getCol().isEmpty()) { // don't prompt user to resolve sync conflict if local collection empty sync("download"); // TODO: Also do reverse check to see if AnkiWeb collection is empty if Anki Desktop // implements it } else { // If can't be resolved then automatically then show conflict resolution dialog showSyncErrorDialog(SyncErrorDialog.DIALOG_SYNC_CONFLICT_RESOLUTION); } } else if (resultType.equals("dbError") || resultType.equals("basicCheckFailed")) { String repairUrl = res.getString(R.string.repair_deck); dialogMessage = res.getString(R.string.sync_corrupt_database, repairUrl); showSyncErrorMessage(joinSyncMessages(dialogMessage, syncMessage)); } else if (resultType.equals("overwriteError")) { dialogMessage = res.getString(R.string.sync_overwrite_error); showSyncErrorMessage(joinSyncMessages(dialogMessage, syncMessage)); } else if (resultType.equals("remoteDbError")) { dialogMessage = res.getString(R.string.sync_remote_db_error); showSyncErrorMessage(joinSyncMessages(dialogMessage, syncMessage)); } else if (resultType.equals("sdAccessError")) { dialogMessage = res.getString(R.string.sync_write_access_error); showSyncErrorMessage(joinSyncMessages(dialogMessage, syncMessage)); } else if (resultType.equals("finishError")) { dialogMessage = res.getString(R.string.sync_log_finish_error); showSyncErrorMessage(joinSyncMessages(dialogMessage, syncMessage)); } else if (resultType.equals("connectionError")) { dialogMessage = res.getString(R.string.sync_connection_error); showSyncErrorMessage(joinSyncMessages(dialogMessage, syncMessage)); } else if (resultType.equals("IOException")) { handleDbError(); } else if (resultType.equals("genericError")) { dialogMessage = res.getString(R.string.sync_generic_error); showSyncErrorMessage(joinSyncMessages(dialogMessage, syncMessage)); } else if (resultType.equals("OutOfMemoryError")) { dialogMessage = res.getString(R.string.error_insufficient_memory); showSyncErrorMessage(joinSyncMessages(dialogMessage, syncMessage)); } else if (resultType.equals("sanityCheckError")) { dialogMessage = res.getString(R.string.sync_sanity_failed); showSyncErrorDialog(SyncErrorDialog.DIALOG_SYNC_SANITY_ERROR, joinSyncMessages(dialogMessage, syncMessage)); } else if (resultType.equals("serverAbort")) { // syncMsg has already been set above, no need to fetch it here. showSyncErrorMessage(joinSyncMessages(dialogMessage, syncMessage)); } else if (resultType.equals("mediaSyncServerError")) { dialogMessage = res.getString(R.string.sync_media_error_check); showSyncErrorDialog(SyncErrorDialog.DIALOG_MEDIA_SYNC_ERROR, joinSyncMessages(dialogMessage, syncMessage)); } else { if (result.length > 1 && result[1] instanceof Integer) { int type = (Integer) result[1]; switch (type) { case 501: dialogMessage = res.getString(R.string.sync_error_501_upgrade_required); break; case 503: dialogMessage = res.getString(R.string.sync_too_busy); break; case 409: dialogMessage = res.getString(R.string.sync_error_409); break; default: dialogMessage = res.getString(R.string.sync_log_error_specific, Integer.toString(type), result[2]); break; } } else if (result[0] instanceof String) { dialogMessage = res.getString(R.string.sync_log_error_specific, Integer.toString(-1), result[0]); } else { dialogMessage = res.getString(R.string.sync_generic_error); } showSyncErrorMessage(joinSyncMessages(dialogMessage, syncMessage)); } } } else { // Sync was successful! if (data.data[2] != null && !data.data[2].equals("")) { // There was a media error, so show it String message = res.getString(R.string.sync_database_acknowledge) + "\n\n" + data.data[2]; showSimpleMessageDialog(message); } else if (data.data.length > 0 && data.data[0] instanceof String && ((String) data.data[0]).length() > 0) { // A full sync occurred String dataString = (String) data.data[0]; if (dataString.equals("upload")) { showSyncLogMessage(R.string.sync_log_uploading_message, syncMessage); } else if (dataString.equals("download")) { showSyncLogMessage(R.string.sync_log_downloading_message, syncMessage); } else { showSyncLogMessage(R.string.sync_database_acknowledge, syncMessage); } } else { // Regular sync completed successfully showSyncLogMessage(R.string.sync_database_acknowledge, syncMessage); } updateDeckList(); WidgetStatus.update(DeckPicker.this); if (mFragmented) { try { loadStudyOptionsFragment(false); } catch (IllegalStateException e) { // Activity was stopped or destroyed when the sync finished. Losing the // fragment here is fine since we build a fresh fragment on resume anyway. Timber.w(e, "Failed to load StudyOptionsFragment after sync."); } } } } }; private String joinSyncMessages(String dialogMessage, String syncMessage) { // If both strings have text, separate them by a new line, otherwise return whichever has text if (!TextUtils.isEmpty(dialogMessage) && !TextUtils.isEmpty(syncMessage)) { return dialogMessage + "\n\n" + syncMessage; } else if (!TextUtils.isEmpty(dialogMessage)) { return dialogMessage; } else { return syncMessage; } } @Override public void loginToSyncServer() { Intent myAccount = new Intent(this, MyAccount.class); myAccount.putExtra("notLoggedIn", true); startActivityForResultWithAnimation(myAccount, LOG_IN_FOR_SYNC, ActivityTransitionAnimation.FADE); } // Callback to import a file -- adding it to existing collection @Override public void importAdd(String importPath) { DeckTask.launchDeckTask(DeckTask.TASK_TYPE_IMPORT, mImportAddListener, new TaskData(importPath, false)); } // Callback to import a file -- replacing the existing collection @Override public void importReplace(String importPath) { DeckTask.launchDeckTask(DeckTask.TASK_TYPE_IMPORT_REPLACE, mImportReplaceListener, new TaskData(importPath)); } @Override public void exportApkg(String filename, Long did, boolean includeSched, boolean includeMedia) { // Export the file to sdcard/AnkiDroid/export regardless of actual col directory, so that we can use FileProvider API File exportDir = new File(CollectionHelper.getDefaultAnkiDroidDirectory(), "export"); exportDir.mkdirs(); File exportPath; if (filename != null) { // filename has been explicitly specified exportPath = new File(exportDir, filename); } else if (did != null) { // filename not explicitly specified, but a deck has been specified so use deck name try { exportPath = new File(exportDir, getCol().getDecks().get(did).getString("name").replaceAll("\\W+", "_") + ".apkg"); } catch (JSONException e) { throw new RuntimeException(e); } } else if (!includeSched) { // full export without scheduling is assumed to be shared with someone else -- use "All Decks.apkg" exportPath = new File(exportDir, "All Decks.apkg"); } else { // full collection export -- use "collection.apkg" File colPath = new File(getCol().getPath()); exportPath = new File(exportDir, colPath.getName().replace(".anki2", ".apkg")); } // add input arguments to new generic structure Object[] inputArgs = new Object[5]; inputArgs[0] = getCol(); inputArgs[1] = exportPath.getPath(); inputArgs[2] = did; inputArgs[3] = includeSched; inputArgs[4] = includeMedia; DeckTask.launchDeckTask(DeckTask.TASK_TYPE_EXPORT_APKG, mExportListener, new TaskData(inputArgs)); } public void emailFile(String path) { // Make sure the file actually exists File attachment = new File(path); if (!attachment.exists()) { Timber.e("Specified apkg file %s does not exist", path); UIUtils.showThemedToast(this, getResources().getString(R.string.apk_share_error), false); return; } // Get a URI for the file to be shared via the FileProvider API Uri uri; try { uri = CompatHelper.getCompat().getExportUri(DeckPicker.this, attachment); } catch (IllegalArgumentException e) { Timber.e("Could not generate a valid URI for the apkg file"); UIUtils.showThemedToast(this, getResources().getString(R.string.apk_share_error), false); return; } Intent shareIntent = ShareCompat.IntentBuilder.from(DeckPicker.this) .setType("application/apkg") .setStream(uri) .setSubject(getString(R.string.export_email_subject, attachment.getName())) .setHtmlText(getString(R.string.export_email_text)) .getIntent(); if (shareIntent.resolveActivity(getPackageManager()) != null) { startActivityWithoutAnimation(shareIntent); } else { Timber.e("Could not find appropriate application to share apkg with"); UIUtils.showThemedToast(this, getResources().getString(R.string.apk_share_error), false); } } /** * Load a new studyOptionsFragment. If withDeckOptions is true, the deck options activity will * be loaded on top of it. Use this flag when creating a new filtered deck to allow the user to * modify the filter settings before being shown the fragment. The fragment itself will handle * rebuilding the deck if the settings change. */ private void loadStudyOptionsFragment(boolean withDeckOptions) { StudyOptionsFragment details = StudyOptionsFragment.newInstance(withDeckOptions); FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); ft.replace(R.id.studyoptions_fragment, details); ft.commit(); } public StudyOptionsFragment getFragment() { Fragment frag = getSupportFragmentManager().findFragmentById(R.id.studyoptions_fragment); if (frag != null && (frag instanceof StudyOptionsFragment)) { return (StudyOptionsFragment) frag; } return null; } /** * Show a message when the SD card is ejected */ private void registerExternalStorageListener() { if (mUnmountReceiver == null) { mUnmountReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { if (intent.getAction().equals(SdCardReceiver.MEDIA_EJECT)) { onSdCardNotMounted(); } else if (intent.getAction().equals(SdCardReceiver.MEDIA_MOUNT)) { restartActivity(); } } }; IntentFilter iFilter = new IntentFilter(); iFilter.addAction(SdCardReceiver.MEDIA_EJECT); iFilter.addAction(SdCardReceiver.MEDIA_MOUNT); registerReceiver(mUnmountReceiver, iFilter); } } public void addSharedDeck() { openUrl(Uri.parse(getResources().getString(R.string.shared_decks_url))); } private void openStudyOptions(boolean withDeckOptions) { if (mFragmented) { // The fragment will show the study options screen instead of launching a new activity. loadStudyOptionsFragment(withDeckOptions); } else { Intent intent = new Intent(); intent.putExtra("withDeckOptions", withDeckOptions); intent.setClass(this, StudyOptionsActivity.class); startActivityForResultWithAnimation(intent, SHOW_STUDYOPTIONS, ActivityTransitionAnimation.LEFT); } } @Override protected void openCardBrowser() { Intent cardBrowser = new Intent(this, CardBrowser.class); cardBrowser.putExtra("selectedDeck", getCol().getDecks().selected()); long lastDeckId = AnkiDroidApp.getSharedPrefs(this).getLong("browserDeckIdFromDeckPicker", -1L); cardBrowser.putExtra("defaultDeckId", lastDeckId); startActivityForResultWithAnimation(cardBrowser, REQUEST_BROWSE_CARDS, ActivityTransitionAnimation.LEFT); } private void handleDeckSelection(long did, boolean dontSkipStudyOptions) { // Clear the undo history when selecting a new deck if (getCol().getDecks().selected() != did) { getCol().clearUndo(); } // Select the deck getCol().getDecks().select(did); // Reset the schedule so that we get the counts for the currently selected deck getCol().getSched().reset(); mFocusedDeck = did; // Get some info about the deck to handle special cases int pos = mDeckListAdapter.findDeckPosition(did); Sched.DeckDueTreeNode deckDueTreeNode = mDeckListAdapter.getDeckList().get(pos); int[] studyOptionsCounts = getCol().getSched().counts(); // Figure out what action to take if (deckDueTreeNode.newCount + deckDueTreeNode.lrnCount + deckDueTreeNode.revCount > 0) { // If there are cards to study then either go to Reviewer or StudyOptions if (mFragmented || dontSkipStudyOptions) { // Go to StudyOptions screen when tablet or deck counts area was clicked openStudyOptions(false); } else { // Otherwise jump straight to the reviewer openReviewer(); } } else if (studyOptionsCounts[0] + studyOptionsCounts[1] + studyOptionsCounts[2] > 0) { // If there are cards due that can't be studied yet (due to the learn ahead limit) then go to study options openStudyOptions(false); } else if (getCol().getSched().newDue() || getCol().getSched().revDue()) { // If there are no cards to review because of the daily study limit then give "Study more" option UIUtils.showSnackbar(this, R.string.studyoptions_limit_reached, false, R.string.study_more, new OnClickListener() { @Override public void onClick(View v) { CustomStudyDialog d = CustomStudyDialog.newInstance( CustomStudyDialog.CONTEXT_MENU_LIMITS, getCol().getDecks().selected(), true); showDialogFragment(d); } }, findViewById(R.id.root_layout), mSnackbarShowHideCallback); // Check if we need to update the fragment or update the deck list. The same checks // are required for all snackbars below. if (mFragmented) { // Tablets must always show the study options that corresponds to the current deck, // regardless of whether the deck is currently reviewable or not. openStudyOptions(false); } else { // On phones, we update the deck list to ensure the currently selected deck is // highlighted correctly. updateDeckList(); } } else if (getCol().getDecks().isDyn(did)) { // Go to the study options screen if filtered deck with no cards to study openStudyOptions(false); } else if (deckDueTreeNode.children.size() == 0 && getCol().cardCount(new Long[]{did}) == 0) { // If the deck is empty and has no children then show a message saying it's empty final Uri helpUrl = Uri.parse(getResources().getString(R.string.link_manual_getting_started)); mayOpenUrl(helpUrl); UIUtils.showSnackbar(this, R.string.empty_deck, false, R.string.help, new OnClickListener() { @Override public void onClick(View v) { openUrl(helpUrl); } }, findViewById(R.id.root_layout), mSnackbarShowHideCallback); if (mFragmented) { openStudyOptions(false); } else { updateDeckList(); } } else { // Otherwise say there are no cards scheduled to study, and give option to do custom study UIUtils.showSnackbar(this, R.string.studyoptions_empty_schedule, false, R.string.custom_study, new OnClickListener() { @Override public void onClick(View v) { CustomStudyDialog d = CustomStudyDialog.newInstance( CustomStudyDialog.CONTEXT_MENU_EMPTY_SCHEDULE, getCol().getDecks().selected(), true); showDialogFragment(d); } }, findViewById(R.id.root_layout), mSnackbarShowHideCallback); if (mFragmented) { openStudyOptions(false); } else { updateDeckList(); } } } /** * Scroll the deck list so that it is centered on the current deck. * * @param did The deck ID of the deck to select. */ private void scrollDecklistToDeck(long did) { int position = mDeckListAdapter.findDeckPosition(did); mRecyclerViewLayoutManager.scrollToPositionWithOffset(position, (mRecyclerView.getHeight() / 2)); } /** * Launch an asynchronous task to rebuild the deck list and recalculate the deck counts. Use this * after any change to a deck (e.g., rename, collapse, add/delete) that needs to be reflected * in the deck list. * * This method also triggers an update for the widget to reflect the newly calculated counts. */ private void updateDeckList() { DeckTask.launchDeckTask(DeckTask.TASK_TYPE_LOAD_DECK_COUNTS, new DeckTask.TaskListener() { @Override public void onPreExecute() { if (!colIsOpen()) { showProgressBar(); } Timber.d("Refreshing deck list"); } @Override public void onPostExecute(TaskData result) { hideProgressBar(); // Make sure the fragment is visible if (mFragmented) { mStudyoptionsFrame.setVisibility(View.VISIBLE); } if (result == null) { Timber.e("null result loading deck counts"); onCollectionLoadError(); return; } List<Sched.DeckDueTreeNode> nodes = (List<Sched.DeckDueTreeNode>) result.getObjArray()[0]; mDeckListAdapter.buildDeckList(nodes, getCol()); // Set the "x due in y minutes" subtitle try { int eta = mDeckListAdapter.getEta(); int due = mDeckListAdapter.getDue(); Resources res = getResources(); if (getCol().cardCount() != -1) { String time = "-"; if (eta != -1) { time = res.getString(R.string.time_quantity_minutes, eta); } if (getSupportActionBar() != null) { getSupportActionBar().setSubtitle(res.getQuantityString(R.plurals.deckpicker_title, due, due, time)); } } } catch (RuntimeException e) { Timber.e(e, "RuntimeException setting time remaining"); } long current = getCol().getDecks().current().optLong("id"); if (mFocusedDeck != current) { scrollDecklistToDeck(current); mFocusedDeck = current; } // Update the mini statistics bar as well AnkiStatsTaskHandler.createReviewSummaryStatistics(getCol(), mReviewSummaryTextView); } @Override public void onProgressUpdate(TaskData... values) { } @Override public void onCancelled() { } }); } // Callback to show study options for currently selected deck public void showContextMenuDeckOptions() { // open deck options if (getCol().getDecks().isDyn(mContextMenuDid)) { // open cram options if filtered deck Intent i = new Intent(DeckPicker.this, FilteredDeckOptions.class); i.putExtra("did", mContextMenuDid); startActivityWithAnimation(i, ActivityTransitionAnimation.FADE); } else { // otherwise open regular options Intent i = new Intent(DeckPicker.this, DeckOptions.class); i.putExtra("did", mContextMenuDid); startActivityWithAnimation(i, ActivityTransitionAnimation.FADE); } } // Callback to show export dialog for currently selected deck public void showContextMenuExportDialog() { exportDeck(mContextMenuDid); } public void exportDeck(long did) { String msg; try { msg = getResources().getString(R.string.confirm_apkg_export_deck, getCol().getDecks().get(did).get("name")); } catch (JSONException e) { throw new RuntimeException(e); } showDialogFragment(ExportDialog.newInstance(msg, did)); } // Callback to show dialog to rename the current deck public void renameDeckDialog() { renameDeckDialog(mContextMenuDid); } public void renameDeckDialog(final long did) { final Resources res = getResources(); mDialogEditText = new EditText(DeckPicker.this); mDialogEditText.setSingleLine(); final String currentName = getCol().getDecks().name(did); mDialogEditText.setText(currentName); mDialogEditText.setSelection(mDialogEditText.getText().length()); new MaterialDialog.Builder(DeckPicker.this) .title(res.getString(R.string.rename_deck)) .customView(mDialogEditText, true) .positiveText(res.getString(R.string.rename)) .negativeText(res.getString(R.string.dialog_cancel)) .callback(new MaterialDialog.ButtonCallback() { @Override public void onPositive(MaterialDialog dialog) { String newName = mDialogEditText.getText().toString().replaceAll("\"", ""); Collection col = getCol(); if (!TextUtils.isEmpty(newName) && !newName.equals(currentName)) { try { col.getDecks().rename(col.getDecks().get(did), newName); } catch (DeckRenameException e) { // We get a localized string from libanki to explain the error UIUtils.showThemedToast(DeckPicker.this, e.getLocalizedMessage(res), false); } } dismissAllDialogFragments(); mDeckListAdapter.notifyDataSetChanged(); updateDeckList(); if (mFragmented) { loadStudyOptionsFragment(false); } } @Override public void onNegative(MaterialDialog dialog) { dismissAllDialogFragments(); } }) .build().show(); } // Callback to show confirm deck deletion dialog before deleting currently selected deck public void confirmDeckDeletion() { confirmDeckDeletion(mContextMenuDid); } public void confirmDeckDeletion(long did) { Resources res = getResources(); if (!colIsOpen()) { return; } if (did == 1) { UIUtils.showSimpleSnackbar(this, R.string.delete_deck_default_deck, true); dismissAllDialogFragments(); return; } // Get the number of cards contained in this deck and its subdecks TreeMap<String, Long> children = getCol().getDecks().children(did); long[] dids = new long[children.size() + 1]; dids[0] = did; int i = 1; for (Long l : children.values()) { dids[i++] = l; } String ids = Utils.ids2str(dids); int cnt = getCol().getDb().queryScalar( "select count() from cards where did in " + ids + " or odid in " + ids); // Delete empty decks without warning if (cnt == 0) { deleteDeck(did); dismissAllDialogFragments(); return; } // Otherwise we show a warning and require confirmation String msg; String deckName = "\'" + getCol().getDecks().name(did) + "\'"; boolean isDyn = getCol().getDecks().isDyn(did); if (isDyn) { msg = res.getString(R.string.delete_cram_deck_message, deckName); } else { msg = res.getQuantityString(R.plurals.delete_deck_message, cnt, deckName, cnt); } showDialogFragment(DeckPickerConfirmDeleteDeckDialog.newInstance(msg)); } // Callback to delete currently selected deck public void deleteContextMenuDeck() { deleteDeck(mContextMenuDid); } public void deleteDeck(final long did) { DeckTask.launchDeckTask(DeckTask.TASK_TYPE_DELETE_DECK, new DeckTask.TaskListener() { // Flag to indicate if the deck being deleted is the current deck. private boolean removingCurrent; @Override public void onPreExecute() { mProgressDialog = StyledProgressDialog.show(DeckPicker.this, "", getResources().getString(R.string.delete_deck), false); if (did == getCol().getDecks().current().optLong("id")) { removingCurrent = true; } } @SuppressWarnings("unchecked") @Override public void onPostExecute(TaskData result) { if (result == null) { return; } // In fragmented mode, if the deleted deck was the current deck, we need to reload // the study options fragment with a valid deck and re-center the deck list to the // new current deck. Otherwise we just update the list normally. if (mFragmented && removingCurrent) { updateDeckList(); openStudyOptions(false); } else { updateDeckList(); } if (mProgressDialog != null && mProgressDialog.isShowing()) { try { mProgressDialog.dismiss(); } catch (Exception e) { Timber.e(e, "onPostExecute - Exception dismissing dialog"); } } // TODO: if we had "undo delete note" like desktop client then we won't need this. getCol().clearUndo(); } @Override public void onProgressUpdate(TaskData... values) { } @Override public void onCancelled() { } }, new TaskData(did)); } /** * Show progress bars and rebuild deck list on completion */ DeckTask.TaskListener mSimpleProgressListener = new DeckTask.TaskListener() { @Override public void onPreExecute() { showProgressBar(); } @Override public void onPostExecute(DeckTask.TaskData result) { updateDeckList(); if (mFragmented) { loadStudyOptionsFragment(false); } } @Override public void onProgressUpdate(TaskData... values) { } @Override public void onCancelled() { } }; public void rebuildFiltered() { getCol().getDecks().select(mContextMenuDid); DeckTask.launchDeckTask(DeckTask.TASK_TYPE_REBUILD_CRAM, mSimpleProgressListener, new DeckTask.TaskData(mFragmented)); } public void emptyFiltered() { getCol().getDecks().select(mContextMenuDid); DeckTask.launchDeckTask(DeckTask.TASK_TYPE_EMPTY_CRAM, mSimpleProgressListener, new DeckTask.TaskData(mFragmented)); } @Override public void onAttachedToWindow() { if (!mFragmented) { Window window = getWindow(); window.setFormat(PixelFormat.RGBA_8888); } } @Override public void onRequireDeckListUpdate() { updateDeckList(); } private void openReviewer() { Intent reviewer = new Intent(this, Reviewer.class); startActivityForResultWithAnimation(reviewer, REQUEST_REVIEW, ActivityTransitionAnimation.LEFT); getCol().startTimebox(); } @Override public void onCreateCustomStudySession() { updateDeckList(); openStudyOptions(false); } @Override public void onExtendStudyLimits() { if (mFragmented) { getFragment().refreshInterface(true); } updateDeckList(); } /** * FAB can't be animated to move out of the way of the snackbar button on API < 11 */ Snackbar.Callback mSnackbarShowHideCallback = new Snackbar.Callback() { @Override public void onDismissed(Snackbar snackbar, int event) { if (!CompatHelper.isHoneycomb()) { final android.support.design.widget.FloatingActionButton b; b = (android.support.design.widget.FloatingActionButton) findViewById(R.id.add_note_action); b.setEnabled(true); } } @Override public void onShown(Snackbar snackbar) { if (!CompatHelper.isHoneycomb()) { final android.support.design.widget.FloatingActionButton b; b = (android.support.design.widget.FloatingActionButton) findViewById(R.id.add_note_action); b.setEnabled(false); } } }; public void handleEmptyCards() { DeckTask.launchDeckTask(DeckTask.TASK_TYPE_FIND_EMPTY_CARDS, new DeckTask.Listener() { @Override public void onPreExecute(DeckTask task) { mProgressDialog = StyledProgressDialog.show(DeckPicker.this, "", getResources().getString(R.string.emtpy_cards_finding), false); } @Override public void onPostExecute(DeckTask task, TaskData result) { final List<Long> cids = (List<Long>) result.getObjArray()[0]; if (cids.size() == 0) { showSimpleMessageDialog(getResources().getString(R.string.empty_cards_none)); } else { String msg = String.format(getResources().getString(R.string.empty_cards_count), cids.size()); ConfirmationDialog dialog = new ConfirmationDialog(); dialog.setArgs(msg); Runnable confirm = new Runnable() { @Override public void run() { getCol().remCards(Utils.arrayList2array(cids)); UIUtils.showSimpleSnackbar(DeckPicker.this, String.format( getResources().getString(R.string.empty_cards_deleted), cids.size()), false); } }; dialog.setConfirm(confirm); showDialogFragment(dialog); } if (mProgressDialog != null && mProgressDialog.isShowing()) { mProgressDialog.dismiss(); } } @Override public void onProgressUpdate(DeckTask task, TaskData... values) { } @Override public void onCancelled() { } }); } }