Merge branch 'feature/macgills/#1251-stale-prs' into feature/macgills/#1214-universal-apk-upload

This commit is contained in:
Sean Mac Gillicuddy 2019-07-01 15:25:26 +01:00
commit ddefce9ff4
23 changed files with 934 additions and 57 deletions

12
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,12 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: https://kiwix.org/support-us/

View File

@ -7,6 +7,8 @@
Kiwix is an offline reader for Web content. One of its main purposes is to make Wikipedia available offline. This is done by reading the content of a file in the ZIM format, a highly compressed open format with additional meta-data.
Kiwix is written in [Kotlin](https://kotlinlang.org/) (with a few old pieces in Java).
[![Build Status](https://travis-ci.org/kiwix/kiwix-android.svg?branch=master)](https://travis-ci.org/kiwix/kiwix-android)
[![IRC Web](https://img.shields.io/badge/chat-on%20freenode-brightgreen.svg)](http://chat.kiwix.org)
[![codecov](https://codecov.io/gh/kiwix/kiwix-android/branch/master/graph/badge.svg)](https://codecov.io/gh/kiwix/kiwix-android)
@ -43,7 +45,8 @@ We currently have a series of automated Unit and Integration tests. These can be
For contributions please read the [CODESTYLE](docs/codestyle.md) carefully. Pull requests that do not match the style will be rejected.
## Commit Style
For writing commit messages please read the [COMMITSTYLE](docs/commitstyle.md) carefully. Kindly adhere to the guidelines. Pull requests not matching the style will be rejected.
For writing commit messages please read the [COMMITSTYLE](docs/commitstyle.md) carefully. Kindly adhere to the guidelines. Pull requests not matching the style will be rejected.
## Communication

View File

@ -123,22 +123,11 @@ dependencies {
implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion"
implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion"
// Leak canary
implementation 'com.squareup.leakcanary:leakcanary-android-no-op:1.6.3'
// Only enable leak canary in debug builds
configurations.all { config ->
if (config.name.contains("debug") || config.name.contains("Debug")) {
config.resolutionStrategy.eachDependency { details ->
if (details.requested.group == "com.squareup.leakcanary" &&
details.requested.name == "leakcanary-android-no-op") {
details.useTarget(group: details.requested.group, name: "leakcanary-android",
version: details.requested.version)
}
}
}
}
releaseImplementation 'com.squareup.leakcanary:leakcanary-android-no-op:1.6.3'
androidTestImplementation 'com.squareup.leakcanary:leakcanary-android-no-op:1.6.3'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:1.6.3'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
implementation "android.arch.lifecycle:extensions:1.1.1"
implementation "io.objectbox:objectbox-kotlin:$objectboxVersion"
@ -197,15 +186,17 @@ private String generateVersionName() {
}
/*
* max version code: 210-0-00-00-00
* our template : UUU-A-ZZ-YY-XX
* max version code: 21-0-0-00-00-00
* our template : UU-D-A-ZZ-YY-XX
* where:
* X = patch version
* Y = minor version
* Z = major version (+ 20 to distinguish from previous, non semantic, versions of the app)
* A = number representing ABI split
* D = number representing density split
* U = unused
*/
private Integer generateVersionCode() {
20 * 10000 +
(ext.versionMajor * 10000) +
@ -282,18 +273,6 @@ android {
testCoverageEnabled true
}
mock_network {
initWith(buildTypes.debug)
matchingFallbacks = ["debug", "release"]
// TODO add DI for the mock network
}
local_download_server {
initWith(buildTypes.debug)
buildConfigField "String", "KIWIX_DOWNLOAD_URL", "\"http://kiwix-download-server/\""
matchingFallbacks = ["debug", "release"]
}
// Release Type
release {
signingConfig signingConfigs.release
@ -444,20 +423,28 @@ android {
}
def abiCodes = ['arm64-v8a': 6, 'x86': 3, 'x86_64': 4, 'armeabi-v7a': 5]
def densityCodes = ['mdpi': 2, 'hdpi': 3, 'xhdpi': 4, 'xxhdpi': 5, 'xxxhdpi': 6]
splits {
abi {
enable true
reset()
include "x86", "x86_64", 'armeabi-v7a', "arm64-v8a"
universalApk buildNumber == "dev"
universalApk true
}
density {
enable true
reset()
include "mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi"
}
}
applicationVariants.all { variant ->
variant.outputs.each { output ->
def baseAbiVersionCode = abiCodes.get(output.getFilter(OutputFile.ABI))
if (baseAbiVersionCode != null) {
output.versionCodeOverride = baseAbiVersionCode * 1000000 + variant.versionCode
}
def baseAbiVersionCode = abiCodes.get(output.getFilter(OutputFile.ABI)) ?: 0
def baseDensityVersionCode = densityCodes.get(output.getFilter(OutputFile.DENSITY)) ?: 1
output.versionCodeOverride =
(baseDensityVersionCode * 10000000) +
(baseAbiVersionCode * 1000000) +
variant.versionCode
}
}
}

View File

@ -3,13 +3,6 @@
android:installLocation="auto"
package="org.kiwix.kiwixmobile">
<supports-screens
android:anyDensity="true"
android:largeScreens="true"
android:normalScreens="true"
android:resizeable="true"
android:smallScreens="true"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.INTERNET"/>

View File

@ -115,10 +115,17 @@ public class ZimContentProvider extends ContentProvider {
return zimFileName;
}
/** Returns path to the current ZIM file */
public static String getZimFile() {
return zimFileName;
}
/**
* Returns title associated with the current ZIM file.
*
* Note that the value returned is NOT unique for each zim file. Versions of the same wiki
* (complete, nopic, novid, etc) may return the same title.
* */
public static String getZimFileTitle() {
if (currentJNIReader == null || zimFileName == null) {
return null;

View File

@ -40,7 +40,7 @@ import org.kiwix.kiwixmobile.data.remote.UserAgentInterceptor;
return new OkHttpClient().newBuilder().followRedirects(true).followSslRedirects(true)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.addNetworkInterceptor(logging)
.addNetworkInterceptor(new UserAgentInterceptor(userAgent)).build();
}

View File

@ -0,0 +1,458 @@
package org.kiwix.kiwixmobile.main;
import android.Manifest;
import android.app.Dialog;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.core.content.FileProvider;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import org.kiwix.kiwixmobile.BuildConfig;
import org.kiwix.kiwixmobile.R;
import org.kiwix.kiwixmobile.data.ZimContentProvider;
import org.kiwix.kiwixmobile.utils.SharedPreferenceUtil;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.Unbinder;
import static org.kiwix.kiwixmobile.utils.Constants.NOTES_DIRECTORY;
/**
* Created by @author Aditya-Sood (21/05/19) as a part of GSoC 2019
*
* AddNoteDialog extends DialogFragment and is used to display the note corresponding to a particular
* article (of a particular zim file/wiki/book) as a full-screen dialog fragment.
*
* Notes are saved as text files at location: "{External Storage}/Kiwix/Notes/ZimFileName/ArticleUrl.txt"
* */
public class AddNoteDialog extends DialogFragment implements ConfirmationAlertDialogFragment.UserClickListener {
public static final String TAG = "AddNoteDialog";
private SharedPreferenceUtil sharedPreferenceUtil;
@BindView(R.id.add_note_toolbar)
Toolbar toolbar; // Displays options for the note dialog
@BindView(R.id.add_note_text_view)
TextView addNoteTextView; // Displays article title
@BindView(R.id.add_note_edit_text)
EditText addNoteEditText; // Displays the note text
private Unbinder unbinder;
private String zimFileTitle;
private String articleTitle;
private String zimNoteDirectoryName; // Corresponds to "ZimFileName" of "{External Storage}/Kiwix/Notes/ZimFileName/ArticleUrl.txt"
private String articleNotefileName; // Corresponds to "ArticleUrl" of "{External Storage}/Kiwix/Notes/ZimFileName/ArticleUrl.txt"
private boolean noteFileExists = false;
private boolean noteEdited = false; // Keeps track of state of the note (whether edited since last save)
private String ZIM_NOTES_DIRECTORY; // Stores path to directory for the currently open zim's notes
public AddNoteDialog(SharedPreferenceUtil sharedPreferenceUtil) {
this.sharedPreferenceUtil = sharedPreferenceUtil;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setStyle(DialogFragment.STYLE_NORMAL, sharedPreferenceUtil.nightMode() ? R.style.AddNoteDialogStyle_Night : R.style.AddNoteDialogStyle);
zimFileTitle = ZimContentProvider.getZimFileTitle();
articleTitle = ((MainActivity)getActivity()).getCurrentWebView().getTitle();
zimNoteDirectoryName = getZimNoteDirectoryName();
articleNotefileName = getArticleNotefileName();
ZIM_NOTES_DIRECTORY = NOTES_DIRECTORY + zimNoteDirectoryName + "/";
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
super.onCreateView(inflater, container, savedInstanceState);
View view = inflater.inflate(R.layout.dialog_add_note, container, false);
unbinder = ButterKnife.bind(this, view);
toolbar.setTitle(R.string.note);
toolbar.setNavigationIcon(R.drawable.ic_close_white_24dp);
toolbar.setNavigationOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
closeKeyboard();
exitAddNoteDialog();
}
});
toolbar.setOnMenuItemClickListener(new Toolbar.OnMenuItemClickListener() {
@Override
public boolean onMenuItemClick(MenuItem item) {
switch (item.getItemId()) {
case R.id.share_note: // Opens app-chooser for sharing the note text file
shareNote();
break;
case R.id.save_note: // Saves the note as a text file
saveNote(addNoteEditText.getText().toString());
break;
}
return true;
}
});
toolbar.inflateMenu(R.menu.menu_add_note_dialog);
// 'Share' disabled for empty notes, 'Save' disabled for unedited notes
disableMenuItems();
addNoteTextView.setText(articleTitle);
// Show the previously saved note if it exists
displayNote();
addNoteEditText.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
noteEdited = true;
enableSaveNoteMenuItem();
enableShareNoteMenuItem();
}
@Override
public void afterTextChanged(Editable s) {}
});
return view;
}
private @NonNull String getZimNoteDirectoryName() {
String zimFileName = ZimContentProvider.getZimFile(); // Returns name of the form ".../Kiwix/granbluefantasy_en_all_all_nopic_2018-10.zim"
String noteDirectoryName = getTextAfterLastSlashWithoutExtension(zimFileName);
return (!noteDirectoryName.isEmpty()) ? noteDirectoryName : zimFileTitle; // Incase the required ZIM file name couldn't be extracted
}
private @NonNull String getArticleNotefileName() {
String articleUrl = ((MainActivity) getActivity()).getCurrentWebView().getUrl(); // Returns url of the form: "content://org.kiwix.kiwixmobile.zim.base/A/Main_Page.html"
String notefileName = getTextAfterLastSlashWithoutExtension(articleUrl);
return (!notefileName.isEmpty()) ? notefileName : articleTitle; // Incase the required html file name couldn't be extracted
}
private @NonNull String getTextAfterLastSlashWithoutExtension(@NonNull String path) {
/* That's about exactly what it does.
*
* From ".../Kiwix/granbluefantasy_en_all_all_nopic_2018-10.zim", returns "granbluefantasy_en_all_all_nopic_2018-10"
* From "content://org.kiwix.kiwixmobile.zim.base/A/Main_Page.html", returns "Main_Page"
* For null input or on being unable to find required text, returns null
* */
int rightmostSlash = path.lastIndexOf('/');
int rightmostDot = path.lastIndexOf('.');
if(rightmostSlash > -1 && rightmostDot > -1) {
return (path.substring(rightmostSlash+1, rightmostDot));
}
return ""; // If couldn't find the dot and/or slash
}
// Override onBackPressed() to respond to user pressing 'Back' button on navigation bar
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
return new Dialog(getActivity(), getTheme()) {
@Override
public void onBackPressed() {
exitAddNoteDialog();
}
};
}
private void exitAddNoteDialog() {
if(noteEdited) {
Fragment previousInstance = getActivity().getSupportFragmentManager().findFragmentByTag(ConfirmationAlertDialogFragment.TAG);
if(previousInstance == null) {
// Custom AlertDialog for taking user confirmation before closing note dialog in case of unsaved changes
DialogFragment newFragment = new ConfirmationAlertDialogFragment(sharedPreferenceUtil, TAG, R.string.confirmation_alert_dialog_message);
newFragment.show(getActivity().getSupportFragmentManager(), ConfirmationAlertDialogFragment.TAG);
}
} else {
// Closing unedited note dialog straightaway
dismissAddNoteDialog();
}
}
private void disableMenuItems() {
if(toolbar.getMenu() != null) {
MenuItem saveItem = toolbar.getMenu().findItem(R.id.save_note);
MenuItem shareItem = toolbar.getMenu().findItem(R.id.share_note);
saveItem.setEnabled(false);
shareItem.setEnabled(false);
saveItem.getIcon().setAlpha(130);
shareItem.getIcon().setAlpha(130);
} else {
Log.d(TAG, "Toolbar without inflated menu");
}
}
private void enableSaveNoteMenuItem() {
if(toolbar.getMenu() != null) {
MenuItem saveItem = toolbar.getMenu().findItem(R.id.save_note);
saveItem.setEnabled(true);
saveItem.getIcon().setAlpha(255);
} else {
Log.d(TAG, "Toolbar without inflated menu");
}
}
private void enableShareNoteMenuItem() {
if(toolbar.getMenu() != null) {
MenuItem shareItem = toolbar.getMenu().findItem(R.id.share_note);
shareItem.setEnabled(true);
shareItem.getIcon().setAlpha(255);
} else {
Log.d(TAG, "Toolbar without inflated menu");
}
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
if(!noteFileExists) {
// Prepare for input in case of empty/new note
addNoteEditText.requestFocus();
showKeyboard();
}
}
public void showKeyboard(){
InputMethodManager inputMethodManager = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
inputMethodManager.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0);
}
public void closeKeyboard(){
InputMethodManager inputMethodManager = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
inputMethodManager.toggleSoftInput(InputMethodManager.HIDE_IMPLICIT_ONLY, 0);
}
private void saveNote(String noteText) {
/* String content of the EditText, given by noteText, is saved into the text file given by:
* "{External Storage}/Kiwix/Notes/ZimFileTitle/ArticleTitle.txt"
* */
if(isExternalStorageWritable()) {
if(ContextCompat.checkSelfPermission(getContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "WRITE_EXTERNAL_STORAGE permission not granted");
showToast(R.string.note_save_unsuccessful, Toast.LENGTH_LONG);
return;
}
File notesFolder = new File(ZIM_NOTES_DIRECTORY);
boolean folderExists = true;
if(!notesFolder.exists()) {
// Try creating folder if it doesn't exist
folderExists = notesFolder.mkdirs();
}
if(folderExists) {
File noteFile = new File(notesFolder.getAbsolutePath(), articleNotefileName + ".txt");
// Save note text-file code:
try {
FileOutputStream fileOutputStream = new FileOutputStream(noteFile);
fileOutputStream.write(noteText.getBytes());
fileOutputStream.close();
showToast(R.string.note_save_successful, Toast.LENGTH_SHORT);
noteEdited = false; // As no unsaved changes remain
} catch (IOException e) {
e.printStackTrace();
showToast(R.string.note_save_unsuccessful, Toast.LENGTH_LONG);
}
} else {
showToast(R.string.note_save_unsuccessful, Toast.LENGTH_LONG);
Log.d(TAG, "Required folder doesn't exist");
}
}
else {
showToast(R.string.note_save_error_storage_not_writable, Toast.LENGTH_LONG);
}
}
private void displayNote() {
/* String content of the note text file given at:
* "{External Storage}/Kiwix/Notes/ZimFileTitle/ArticleTitle.txt"
* is displayed in the EditText field (note content area)
* */
File noteFile = new File(ZIM_NOTES_DIRECTORY + articleNotefileName + ".txt");
if(noteFile.exists()) {
noteFileExists = true;
StringBuilder contents = new StringBuilder();
try {
BufferedReader input = new BufferedReader(new java.io.FileReader(noteFile));
try {
String line = null;
while((line = input.readLine()) != null) {
contents.append(line);
contents.append(System.getProperty("line.separator"));
}
} catch (IOException e) {
e.printStackTrace();
Log.d(TAG, "Error reading line with BufferedReader");
} finally {
input.close();
}
} catch (IOException e) {
e.printStackTrace();
Log.d(TAG, "Error closing BufferedReader");
}
addNoteEditText.setText(contents.toString()); // Display the note content
enableShareNoteMenuItem(); // As note content exists which can be shared
}
// No action in case the note file for the currently open article doesn't exist
}
private void shareNote() {
/* The note text file corresponding to the currently open article, given at:
* "{External Storage}/Kiwix/Notes/ZimFileTitle/ArticleTitle.txt"
* is shared via an app-chooser intent
* */
if(noteEdited) {
saveNote(addNoteEditText.getText().toString()); // Save edited note before sharing the text file
}
File noteFile = new File(ZIM_NOTES_DIRECTORY + articleNotefileName + ".txt");
Uri noteFileUri = null;
if(noteFile.exists()) {
if (Build.VERSION.SDK_INT >= 24) {
// From Nougat 7 (API 24) access to files is shared temporarily with other apps
// Need to use FileProvider for the same
noteFileUri = FileProvider.getUriForFile(getContext(), BuildConfig.APPLICATION_ID+".fileprovider", noteFile);
} else {
noteFileUri = Uri.fromFile(noteFile);
}
} else {
showToast(R.string.note_share_error_file_missing, Toast.LENGTH_SHORT);
}
if(noteFileUri != null) {
Intent noteFileShareIntent = new Intent(Intent.ACTION_SEND);
noteFileShareIntent.setType("application/octet-stream");
noteFileShareIntent.putExtra(Intent.EXTRA_STREAM, noteFileUri);
noteFileShareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
Intent shareChooser = Intent.createChooser(noteFileShareIntent, getString(R.string.note_share_app_chooser_title));
if(noteFileShareIntent.resolveActivity(getActivity().getPackageManager()) != null) {
startActivity(shareChooser);
}
}
}
public static boolean isExternalStorageWritable() {
return Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState());
}
private void showToast(int stringResource, int duration) {
Toast.makeText(getActivity(), stringResource, duration).show();
}
// Methods from ConfirmationAlertDialogFragment.UserClickListener interface
@Override
public void onPositiveClick() {
dismissAddNoteDialog();
}
@Override
public void onNegativeClick() {
// Do nothing
}
private void dismissAddNoteDialog() {
Dialog dialog = getDialog();
dialog.dismiss();
}
@Override
public void onStart() {
super.onStart();
Dialog dialog = getDialog();
if(dialog != null) {
int width = ViewGroup.LayoutParams.MATCH_PARENT;
int height = ViewGroup.LayoutParams.MATCH_PARENT;
dialog.getWindow().setLayout(width, height);
}
}
@Override
public void onDestroyView() {
super.onDestroyView();
if (unbinder != null) {
unbinder.unbind();
}
}
}

View File

@ -0,0 +1,83 @@
package org.kiwix.kiwixmobile.main;
import android.app.Dialog;
import android.content.DialogInterface;
import android.os.Bundle;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import org.kiwix.kiwixmobile.R;
import org.kiwix.kiwixmobile.utils.SharedPreferenceUtil;
/**
* Created by @Aditya-Sood as a part of GSoC 2019
*
* This is a generic helper class for displaying a 2-button (positive & negative) confirmation dialog fragment on top of an existing dialog fragment
* - Only for confirmation dialogs with a Positive & Negative button
* - If you also need a Neutral button, add it selectively (if-else) for the required use case (Take care of the callback interface as well)
*
* Currently used as:
* - Helper class to show the alert dialog in case the user tries to exit the {@link AddNoteDialog} with unsaved file changes
*
**/
public class ConfirmationAlertDialogFragment extends DialogFragment {
public static String TAG = "ConfirmationAlertDialog";
private SharedPreferenceUtil sharedPreferenceUtil;
private int stringResourceId;
private String parentDialogFragmentTAG;
public ConfirmationAlertDialogFragment(SharedPreferenceUtil sharedPreferenceUtil, String parentDialogFragmentTAG, int stringResourceId) {
this.sharedPreferenceUtil = sharedPreferenceUtil;
this.parentDialogFragmentTAG = parentDialogFragmentTAG;
this.stringResourceId = stringResourceId;
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Fragment parentDialogFragment = getFragmentManager().findFragmentByTag(parentDialogFragmentTAG);
AlertDialog.Builder builder;
if (sharedPreferenceUtil != null && sharedPreferenceUtil.nightMode()) { // Night Mode support
builder = new AlertDialog.Builder(getActivity(), R.style.AppTheme_Dialog_Night);
} else {
builder = new AlertDialog.Builder(getActivity());
}
builder.setMessage(stringResourceId)
.setPositiveButton(R.string.yes, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
if(parentDialogFragment != null) {
((UserClickListener) parentDialogFragment).onPositiveClick();
}
}
})
.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
if(parentDialogFragment != null) {
((UserClickListener) parentDialogFragment).onNegativeClick();
}
}
});
return builder.create();
}
/** Callback interface for responding to user clicks to a {@link ConfirmationAlertDialogFragment} dialog */
public interface UserClickListener {
void onPositiveClick();
void onNegativeClick();
}
}

View File

@ -25,6 +25,7 @@ import android.appwidget.AppWidgetManager;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
@ -36,6 +37,7 @@ import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.CountDownTimer;
import android.os.Environment;
import android.os.Handler;
import android.provider.Settings;
import android.text.SpannableString;
@ -70,6 +72,8 @@ import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentTransaction;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@ -131,11 +135,15 @@ import static org.kiwix.kiwixmobile.utils.Constants.EXTRA_LIBRARY;
import static org.kiwix.kiwixmobile.utils.Constants.EXTRA_SEARCH;
import static org.kiwix.kiwixmobile.utils.Constants.EXTRA_ZIM_FILE;
import static org.kiwix.kiwixmobile.utils.Constants.EXTRA_ZIM_FILE_2;
import static org.kiwix.kiwixmobile.utils.Constants.NOTES_DIRECTORY;
import static org.kiwix.kiwixmobile.utils.Constants.PREF_KIWIX_MOBILE;
import static org.kiwix.kiwixmobile.utils.Constants.REQUEST_FILE_SEARCH;
import static org.kiwix.kiwixmobile.utils.Constants.REQUEST_FILE_SELECT;
import static org.kiwix.kiwixmobile.utils.Constants.REQUEST_HISTORY_ITEM_CHOSEN;
import static org.kiwix.kiwixmobile.utils.Constants.REQUEST_PREFERENCES;
import static org.kiwix.kiwixmobile.utils.Constants.REQUEST_READ_STORAGE_PERMISSION;
import static org.kiwix.kiwixmobile.utils.Constants.REQUEST_STORAGE_PERMISSION;
import static org.kiwix.kiwixmobile.utils.Constants.REQUEST_WRITE_STORAGE_PERMISSION_ADD_NOTE;
import static org.kiwix.kiwixmobile.utils.Constants.RESULT_HISTORY_CLEARED;
import static org.kiwix.kiwixmobile.utils.Constants.RESULT_RESTART;
import static org.kiwix.kiwixmobile.utils.Constants.TAG_CURRENT_ARTICLES;
@ -151,8 +159,6 @@ import static org.kiwix.kiwixmobile.utils.UpdateUtils.reformatProviderUrl;
public class MainActivity extends BaseActivity implements WebViewCallback,
MainContract.View{
private static final int REQUEST_READ_STORAGE_PERMISSION = 2;
private static final int REQUEST_HISTORY_ITEM_CHOSEN = 99;
private static final String NEW_TAB = "NEW_TAB";
private static final String HOME_URL = "file:///android_asset/home.html";
public static boolean isFullscreenOpened;
@ -445,11 +451,7 @@ public class MainActivity extends BaseActivity implements WebViewCallback,
hideTabSwitcher();
selectTab(position);
/* Bug Fix
* Issue #592 in which the navigational history of the previously open tab (WebView) was
* carried forward to the newly selected/opened tab; causing erroneous enabling of
* navigational buttons.
*/
/* Bug Fix #592 */
updateBottomToolbarArrowsAlpha();
}
@ -527,7 +529,7 @@ public class MainActivity extends BaseActivity implements WebViewCallback,
drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED);
closeAllTabsButton.setImageDrawable(
ContextCompat.getDrawable(this, R.drawable.ic_close_white_24dp));
ContextCompat.getDrawable(this, R.drawable.ic_close_black_24dp));
tabSwitcherRoot.setVisibility(View.GONE);
progressBar.setVisibility(View.VISIBLE);
contentFrame.setVisibility(View.VISIBLE);
@ -856,6 +858,19 @@ public class MainActivity extends BaseActivity implements WebViewCallback,
compatCallback.showSoftInput();
break;
case R.id.menu_add_note:
if(requestExternalStorageWritePermissionForNotes()) {
// Check permission since notes are stored in the public-external storage
showAddNoteDialog();
}
break;
case R.id.menu_clear_notes:
if(requestExternalStorageWritePermissionForNotes()) { // Check permission since notes are stored in the public-external storage
showClearAllNotesDialog();
}
break;
case R.id.menu_bookmarks_list:
goToBookmarks();
break;
@ -915,6 +930,117 @@ public class MainActivity extends BaseActivity implements WebViewCallback,
return super.onOptionsItemSelected(item);
}
/** Dialog to take user confirmation before deleting all notes */
private void showClearAllNotesDialog() {
AlertDialog.Builder builder;
if (sharedPreferenceUtil != null && sharedPreferenceUtil.nightMode()) { // Night Mode support
builder = new AlertDialog.Builder(this, R.style.AppTheme_Dialog_Night);
} else {
builder = new AlertDialog.Builder(this);
}
builder.setMessage(R.string.delete_notes_confirmation_msg)
.setNegativeButton(android.R.string.cancel, null) // Do nothing for 'Cancel' button
.setPositiveButton(R.string.yes, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
clearAllNotes();
}
})
.show();
}
/** Method to delete all user notes */
private void clearAllNotes() {
boolean result = true; // Result of all delete() calls is &&-ed to this variable
if(AddNoteDialog.isExternalStorageWritable()) {
if(ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
Log.d("MainActivity", "WRITE_EXTERNAL_STORAGE permission not granted");
showToast(R.string.ext_storage_permission_not_granted, Toast.LENGTH_LONG);
return;
}
// TODO: Replace below code with Kotlin's deleteRecursively() method
File notesDirectory = new File(NOTES_DIRECTORY);
File[] filesInNotesDirectory = notesDirectory.listFiles();
if(filesInNotesDirectory == null) { // Notes folder doesn't exist
showToast(R.string.notes_deletion_none_found, Toast.LENGTH_LONG);
return;
}
for(File wikiFileDirectory : filesInNotesDirectory) {
if(wikiFileDirectory.isDirectory()) {
File[] filesInWikiDirectory = wikiFileDirectory.listFiles();
for(File noteFile : filesInWikiDirectory) {
if(noteFile.isFile()) {
result = result && noteFile.delete();
}
}
}
result = result && wikiFileDirectory.delete(); // Wiki specific notes directory deleted
}
result = result && notesDirectory.delete(); // "{External Storage}/Kiwix/Notes" directory deleted
}
if(result) {
showToast(R.string.notes_deletion_successful, Toast.LENGTH_SHORT);
} else {
showToast(R.string.notes_deletion_unsuccessful, Toast.LENGTH_SHORT);
}
}
/** Creates the full screen AddNoteDialog, which is a DialogFragment */
private void showAddNoteDialog() {
FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction();
Fragment previousInstance = getSupportFragmentManager().findFragmentByTag(AddNoteDialog.TAG);
// To prevent multiple instances of the DialogFragment
if(previousInstance == null) {
/* Since the DialogFragment is never added to the back-stack, so findFragmentByTag()
* returning null means that the AddNoteDialog is currently not on display (as doesn't exist)
**/
AddNoteDialog dialogFragment = new AddNoteDialog(sharedPreferenceUtil);
dialogFragment.show(fragmentTransaction, AddNoteDialog.TAG); // For DialogFragments, show() handles the fragment commit and display
}
}
private boolean requestExternalStorageWritePermissionForNotes() {
if(Build.VERSION.SDK_INT >= 23) { // For Marshmallow & higher API levels
if(checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
return true;
} else {
if(shouldShowRequestPermissionRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
/* shouldShowRequestPermissionRationale() returns false when:
* 1) User has previously checked on "Don't ask me again", and/or
* 2) Permission has been disabled on device
*/
showToast(R.string.ext_storage_permission_rationale_add_note, Toast.LENGTH_LONG);
}
requestPermissions(new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQUEST_WRITE_STORAGE_PERMISSION_ADD_NOTE);
}
} else { // For Android versions below Marshmallow 6.0 (API 23)
return true; // As already requested at install time
}
return false;
}
private void showToast(int stringResource, int duration) {
Toast.makeText(this, stringResource, duration).show();
}
@SuppressWarnings("SameReturnValue")
@OnLongClick(R.id.bottom_toolbar_bookmark)
boolean goToBookmarks() {
@ -1084,6 +1210,20 @@ public class MainActivity extends BaseActivity implements WebViewCallback,
startActivity(intent);
}).show();
}
break;
}
case REQUEST_WRITE_STORAGE_PERMISSION_ADD_NOTE: {
if(grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// Successfully granted permission, so opening the note keeper
showAddNoteDialog();
} else {
Toast.makeText(getApplicationContext(), getString(R.string.ext_storage_write_permission_denied_add_note), Toast.LENGTH_LONG);
}
break;
}
}
}

View File

@ -86,7 +86,7 @@ public class AnimationUtils {
public void onAnimationStart(Animation animation) {
v.setImageDrawable(
ContextCompat.getDrawable(v.getContext(), R.drawable.ic_close_white_24dp));
ContextCompat.getDrawable(v.getContext(), R.drawable.ic_close_black_24dp));
}
});
}

View File

@ -17,6 +17,8 @@
*/
package org.kiwix.kiwixmobile.utils;
import android.os.Environment;
import org.kiwix.kiwixmobile.BuildConfig;
public final class Constants {
@ -30,6 +32,12 @@ public final class Constants {
public static final int REQUEST_STORAGE_PERMISSION = 1;
public static final int REQUEST_READ_STORAGE_PERMISSION = 2;
public static final int REQUEST_WRITE_STORAGE_PERMISSION_ADD_NOTE = 3;
public static final int REQUEST_HISTORY_ITEM_CHOSEN = 99;
public static final int REQUEST_FILE_SELECT = 1234;
public static final int REQUEST_PREFERENCES = 1235;
@ -130,4 +138,7 @@ public final class Constants {
public static final String OLD_PROVIDER_DOMAIN = "org.kiwix.zim.base";
public static final String NEW_PROVIDER_DOMAIN = BuildConfig.APPLICATION_ID + ".zim.base";
// Path Constants
public static final String NOTES_DIRECTORY = Environment.getExternalStorageDirectory() + "/Kiwix/Notes/";
}

View File

@ -144,7 +144,7 @@ class ZimManageViewModel @Inject constructor(
.subscribe(
{
kiwixService.library
.timeout(10, SECONDS)
.timeout(60, SECONDS)
.retry(5)
.subscribe(
{ library.onNext(it) },

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M3,18h12v-2L3,16v2zM3,6v2h18L21,6L3,6zM3,13h18v-2L3,11v2z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M15,16h4v2h-4zM15,8h7v2h-7zM15,12h6v2h-6zM3,18c0,1.1 0.9,2 2,2h6c1.1,0 2,-0.9 2,-2L13,8L3,8v10zM14,5h-3l-1,-1L6,4L5,5L2,5v2h12z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:tint="#000000"
android:viewportHeight="24.0"
android:viewportWidth="24.0"
android:width="24dp">
<path
android:fillColor="#000000"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
</vector>

View File

@ -1,6 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:tint="#000000"
android:tint="#ffffff"
android:viewportHeight="24.0"
android:viewportWidth="24.0"
android:width="24dp">

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9L5,9L5,5h10v4z"/>
</vector>

View File

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_add_note_dialog"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/add_note_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
</androidx.appcompat.widget.Toolbar>
</com.google.android.material.appbar.AppBarLayout>
<ScrollView
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/app_bar_add_note_dialog"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent">
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/add_note_text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1"
android:background="@android:color/transparent"
android:hint="@string/wiki_article_title"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
android:paddingTop="10dp"
android:paddingLeft="20dp"
android:paddingRight="20dp"/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginLeft="15dp"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
android:layout_marginRight="15dp"
android:background="#000000"/>
<EditText
android:id="@+id/add_note_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="5dp"
android:paddingBottom="10dp"
android:paddingLeft="20dp"
android:paddingRight="20dp"
android:inputType="textMultiLine|textCapSentences|textAutoCorrect"
android:minLines="6"
android:gravity="top|left"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:scrollbars="vertical"
android:background="@android:color/transparent"
android:hint="@string/note"/>
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -25,6 +25,6 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:srcCompat="@drawable/ic_close_white_24dp"
app:srcCompat="@drawable/ic_close_black_24dp"
/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:id="@+id/share_note"
android:icon="@drawable/baseline_share_24"
android:title="@string/share"
app:showAsAction="always"/>
<item android:id="@+id/save_note"
android:icon="@drawable/ic_save"
android:title="@string/save"
app:showAsAction="always"/>
</menu>

View File

@ -14,6 +14,18 @@
app:actionLayout="@layout/ic_tab_switcher"
app:showAsAction="always"/>
<item
android:id="@+id/menu_add_note"
android:title="@string/add_note"
android:icon="@drawable/ic_add_note"
app:showAsAction="always"/>
<item
android:id="@+id/menu_clear_notes"
android:title="@string/clear_all_notes"
android:icon="@drawable/ic_baseline_delete_sweep_24px"
app:showAsAction="ifRoom"/>
<item
android:id="@+id/menu_new_tab"
android:title="@string/new_tab"

View File

@ -264,4 +264,22 @@
<string name="failed_unhandled_http_code">Unknown HTTP code received</string>
<string name="failed_unknown">Unknown</string>
<string name="failed_http_code">HTTP code %s</string>
</resources>
<string name="add_note">Add Note</string>
<string name="save">Save</string>
<string name="note">Note</string>
<string name="wiki_article_title">Wiki Article Title</string>
<string name="ext_storage_permission_rationale_add_note">Storage access is required for Notes</string>
<string name="ext_storage_write_permission_denied_add_note">Notes can\'t be used without access to storage</string>
<string name="note_save_unsuccessful">Note save unsuccessful</string>
<string name="note_save_successful">Note saved</string>
<string name="note_save_error_storage_not_writable">Error saving note: Storage not writable</string>
<string name="note_share_error_file_missing">Note file doesn\'t exist</string>
<string name="note_share_app_chooser_title">Share note file with:</string>
<string name="confirmation_alert_dialog_message">Discard unsaved changes?</string>
<string name="clear_all_notes">Clear All Notes</string>
<string name="delete_notes_confirmation_msg">Delete all notes?</string>
<string name="ext_storage_permission_not_granted">Error: Storage permissions not granted</string>
<string name="notes_deletion_none_found">No notes found for deletion</string>
<string name="notes_deletion_successful">Entire notes folder deleted</string>
<string name="notes_deletion_unsuccessful">Some files not deleted</string>
</resources>

View File

@ -129,5 +129,29 @@
<style name="NeutralButtonStyle" parent="Widget.AppCompat.Button.ButtonBar.AlertDialog">
<item name="android:textColor">@color/accent</item>
</style>
</resources>
<style name="AddNoteDialogStyle" parent="Theme.AppCompat.Dialog">
<item name="android:windowNoTitle">true</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorPrimary">@color/primary</item>
<item name="android:textColor">#000000</item>
<item name="android:editTextColor">#000000</item>
<item name="android:textColorHint">@color/textDarkTertiary</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowIsFloating">false</item>
<item name="android:windowBackground">@color/white</item>
</style>
<style name="AddNoteDialogStyle.Night" parent="Theme.AppCompat.Dialog">
<item name="android:windowNoTitle">true</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorPrimary">@color/primary</item>
<item name="android:textColor">#ffffff</item>
<item name="android:editTextColor">#ffffff</item>
<item name="android:textColorHint">@color/grey</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowIsFloating">false</item>
<item name="android:windowBackground">@color/cardview_dark_background</item>
</style>
</resources>