app/src/main/java/net/astropenguin/naroureader/MainActivity.java
raw · 25527 bytes
package net.astropenguin.naroureader;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Color;
import android.graphics.drawable.GradientDrawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.InputType;
import android.view.Gravity;
import android.view.View;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ProgressBar;
import android.widget.Toast;
import org.json.JSONObject;
import org.mozilla.geckoview.AllowOrDeny;
import org.mozilla.geckoview.GeckoResult;
import org.mozilla.geckoview.GeckoSession;
import org.mozilla.geckoview.GeckoView;
import org.mozilla.geckoview.WebExtension;
import org.mozilla.geckoview.WebRequestError;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class MainActivity extends Activity {
private static final String DEFAULT_URL = "https://ncode.syosetu.com/n0552ep/75/";
private static final Pattern SHARED_URL = Pattern.compile("https://ncode\\.syosetu\\.com/[^\\s<>\\\"]*");
private GeckoSession session;
private GeckoView geckoView;
private FrameLayout root;
private boolean darkReaderTheme;
private Button menuButton;
private ProgressBar progress;
private SharedPreferences prefs;
private boolean canGoBack, ready, destroyed, reading;
private String pendingUrl, currentUrl;
private AlertDialog menuDialog;
private TiltSensor tilt;
private boolean resumed;
private LanguagePacks languages;
private String tr(String key) { return languages.text(key); }
@Override public void onCreate(Bundle state) {
super.onCreate(state);
tilt = new TiltSensor(this);
prefs = getSharedPreferences("reader", MODE_PRIVATE);
languages = new LanguagePacks(this, prefs.getString("readerLanguage", null));
currentUrl = prefs.getString("lastUrl", DEFAULT_URL);
pendingUrl = intentUrl(getIntent());
if (pendingUrl == null) pendingUrl = currentUrl;
if (!isInternal(pendingUrl)) pendingUrl = DEFAULT_URL;
getWindow().setStatusBarColor(Color.TRANSPARENT);
getWindow().setNavigationBarColor(Color.TRANSPARENT);
if (Build.VERSION.SDK_INT >= 28) {
WindowManager.LayoutParams attr = getWindow().getAttributes();
attr.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
getWindow().setAttributes(attr);
}
root = new FrameLayout(this);
geckoView = new GeckoView(this);
root.addView(geckoView, new FrameLayout.LayoutParams(-1, -1));
progress = new ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal);
progress.setMax(100);
root.addView(progress, new FrameLayout.LayoutParams(-1, dp(3), Gravity.TOP));
menuButton = new Button(this);
menuButton.setText("☰");
menuButton.setTextSize(22);
menuButton.setTextColor(Color.WHITE);
menuButton.setContentDescription(tr("native.menu"));
GradientDrawable bg = new GradientDrawable();
bg.setColor(Color.rgb(37, 72, 63)); bg.setCornerRadius(dp(28));
menuButton.setBackground(bg); menuButton.setElevation(dp(5));
FrameLayout.LayoutParams menuLayout = new FrameLayout.LayoutParams(dp(52), dp(52), Gravity.BOTTOM | Gravity.END);
menuLayout.setMargins(dp(16), dp(16), dp(16), dp(16));
root.addView(menuButton, menuLayout);
menuButton.setOnClickListener(v -> showMenu());
if (Build.VERSION.SDK_INT >= 28) root.setOnApplyWindowInsetsListener((v, insets) -> {
android.view.DisplayCutout cutout = insets.getDisplayCutout();
if (cutout != null) root.setPadding(cutout.getSafeInsetLeft(), cutout.getSafeInsetTop(), cutout.getSafeInsetRight(), cutout.getSafeInsetBottom());
else root.setPadding(0, 0, 0, 0);
return insets;
});
setContentView(root);
applyReaderTheme(prefs.getString("readerTheme", "paper"));
if (Build.VERSION.SDK_INT >= 33) getOnBackInvokedDispatcher().registerOnBackInvokedCallback(
android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT, this::navigateBack);
immersive();
ReaderApplication app = (ReaderApplication) getApplication();
session = new GeckoSession();
session.setContentDelegate(new GeckoSession.ContentDelegate() {
@Override public void onCrash(GeckoSession s) {
reading = false; tilt.stop(); menuButton.setVisibility(View.VISIBLE);
new AlertDialog.Builder(MainActivity.this).setTitle(tr("native.stopped"))
.setMessage(tr("native.stopped.help"))
.setPositiveButton(tr("native.reopen"), (d, w) -> recreate()).setNegativeButton(tr("native.close"), (d, w) -> finish()).show();
}
});
session.setPromptDelegate(new GeckoSession.PromptDelegate() {
@Override public GeckoResult<PromptResponse> onColorPrompt(GeckoSession s, ColorPrompt prompt) {
GeckoResult<PromptResponse> result = new GeckoResult<>();
android.widget.LinearLayout picker = new android.widget.LinearLayout(MainActivity.this);
picker.setOrientation(android.widget.LinearLayout.VERTICAL);
picker.setPadding(dp(20), dp(12), dp(20), dp(12));
View swatch = new View(MainActivity.this);
picker.addView(swatch, new android.widget.LinearLayout.LayoutParams(-1, dp(48)));
EditText hex = new EditText(MainActivity.this);
hex.setSingleLine(true); hex.setHint("#RRGGBB");
hex.setContentDescription(tr("native.hex"));
hex.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
picker.addView(hex);
String initial = prompt.defaultValue;
if (initial == null || !initial.matches("#[0-9a-fA-F]{6}")) initial = "#000000";
int color = Color.parseColor(initial);
android.widget.SeekBar[] channels = new android.widget.SeekBar[3];
int[] values = {Color.red(color), Color.green(color), Color.blue(color)};
String[] names = {tr("native.red"), tr("native.green"), tr("native.blue")};
for (int i = 0; i < 3; i++) {
android.widget.TextView label = new android.widget.TextView(MainActivity.this);
label.setText(names[i]); picker.addView(label);
channels[i] = new android.widget.SeekBar(MainActivity.this);
channels[i].setContentDescription(names[i]); channels[i].setMax(255);
channels[i].setProgress(values[i]); picker.addView(channels[i]);
}
hex.addTextChangedListener(new android.text.TextWatcher() {
@Override public void beforeTextChanged(CharSequence text, int start, int count, int after) {}
@Override public void onTextChanged(CharSequence text, int start, int before, int count) {
String value = text.toString().trim();
if (!value.matches("#[0-9a-fA-F]{6}")) return;
hex.setError(null);
int selected = Color.parseColor(value); swatch.setBackgroundColor(selected);
channels[0].setProgress(Color.red(selected));
channels[1].setProgress(Color.green(selected));
channels[2].setProgress(Color.blue(selected));
}
@Override public void afterTextChanged(android.text.Editable text) {}
});
for (android.widget.SeekBar channel : channels) channel.setOnSeekBarChangeListener(new android.widget.SeekBar.OnSeekBarChangeListener() {
@Override public void onProgressChanged(android.widget.SeekBar bar, int value, boolean fromUser) {
if (fromUser) hex.setText(String.format(java.util.Locale.ROOT, "#%02x%02x%02x",
channels[0].getProgress(), channels[1].getProgress(), channels[2].getProgress()));
}
@Override public void onStartTrackingTouch(android.widget.SeekBar bar) {}
@Override public void onStopTrackingTouch(android.widget.SeekBar bar) {}
});
hex.setText(initial);
android.widget.ScrollView scroll = new android.widget.ScrollView(MainActivity.this);
scroll.addView(picker);
AlertDialog dialog = new AlertDialog.Builder(MainActivity.this)
.setTitle(tr("native.color")).setView(scroll)
.setPositiveButton(tr("native.apply"), null).setNegativeButton(tr("native.cancel"), null).create();
prompt.setDelegate(new PromptInstanceDelegate() {
@Override public void onPromptDismiss(BasePrompt p) { dialog.dismiss(); }
});
dialog.setOnDismissListener(d -> {
if (!prompt.isComplete()) result.complete(prompt.dismiss());
immersive();
});
dialog.setOnShowListener(d -> {
dialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {
String value = hex.getText().toString().trim();
if (!value.matches("#[0-9a-fA-F]{6}")) { hex.setError(tr("native.hex.error")); return; }
if (!prompt.isComplete()) result.complete(prompt.confirm(value));
dialog.dismiss();
});
});
dialog.show();
return result;
}
@Override public GeckoResult<PromptResponse> onChoicePrompt(GeckoSession s, ChoicePrompt prompt) {
// The reader uses a single-choice HTML select for its theme.
if (prompt.type == ChoicePrompt.Type.MULTIPLE) return GeckoResult.fromValue(prompt.dismiss());
java.util.ArrayList<ChoicePrompt.Choice> choices = new java.util.ArrayList<>();
flattenChoices(prompt.choices, choices);
String[] labels = new String[choices.size()];
int selected = -1;
for (int i = 0; i < choices.size(); i++) {
labels[i] = choices.get(i).label;
if (choices.get(i).selected) selected = i;
}
GeckoResult<PromptResponse> result = new GeckoResult<>();
AlertDialog dialog = new AlertDialog.Builder(MainActivity.this)
.setTitle(prompt.title == null ? tr("native.choose") : prompt.title)
.setSingleChoiceItems(labels, selected, (d, which) -> {
if (!prompt.isComplete()) result.complete(prompt.confirm(choices.get(which)));
d.dismiss();
}).setNegativeButton(tr("native.cancel"), (d, which) -> {
if (!prompt.isComplete()) result.complete(prompt.dismiss());
}).create();
dialog.setOnCancelListener(d -> { if (!prompt.isComplete()) result.complete(prompt.dismiss()); });
prompt.setDelegate(new PromptInstanceDelegate() {
@Override public void onPromptDismiss(BasePrompt p) { dialog.dismiss(); }
});
dialog.setOnDismissListener(d -> immersive());
dialog.show();
return result;
}
});
session.setNavigationDelegate(new GeckoSession.NavigationDelegate() {
@Override public void onCanGoBack(GeckoSession s, boolean value) { canGoBack = value; }
@Override public void onLocationChange(GeckoSession s, String url,
List<GeckoSession.PermissionDelegate.ContentPermission> permissions, Boolean gesture) {
if (url != null && isInternal(url)) {
currentUrl = url;
// Keep resume pointed at a novel, not an incidental login or help page.
if ("ncode.syosetu.com".equals(Uri.parse(url).getHost())) prefs.edit().putString("lastUrl", url).apply();
}
}
@Override public GeckoResult<AllowOrDeny> onLoadRequest(GeckoSession s, LoadRequest request) {
if ("about:license".equals(request.uri)) return GeckoResult.fromValue(AllowOrDeny.ALLOW);
if (isInternal(request.uri)) {
if (request.target == TARGET_WINDOW_NEW) {
geckoView.post(() -> session.loadUri(request.uri));
return GeckoResult.fromValue(AllowOrDeny.DENY);
}
return GeckoResult.fromValue(AllowOrDeny.ALLOW);
}
if (request.hasUserGesture && isWeb(request.uri)) openExternal(request.uri);
return GeckoResult.fromValue(AllowOrDeny.DENY);
}
@Override public GeckoResult<String> onLoadError(GeckoSession s, String uri, WebRequestError error) {
geckoView.post(() -> {
progress.setVisibility(View.GONE);
menuButton.setVisibility(View.VISIBLE);
Toast.makeText(MainActivity.this, tr("native.load.error"), Toast.LENGTH_LONG).show();
});
return null; // Gecko's error page preserves certificate and network error details.
}
});
session.setProgressDelegate(new GeckoSession.ProgressDelegate() {
@Override public void onPageStart(GeckoSession s, String url) {
reading = false; tilt.stop(); menuButton.setVisibility(View.VISIBLE); progress.setProgress(0); progress.setVisibility(View.VISIBLE);
}
@Override public void onProgressChange(GeckoSession s, int value) { progress.setProgress(value); }
@Override public void onPageStop(GeckoSession s, boolean success) { progress.setVisibility(View.GONE); }
});
session.open(app.runtime());
geckoView.setSession(session);
app.extension().accept(extension -> {
if (destroyed) return;
session.getWebExtensionController().setMessageDelegate(extension, new WebExtension.MessageDelegate() {
@Override public GeckoResult<Object> onMessage(String name, Object message, WebExtension.MessageSender sender) {
if (sender.session != session || sender.url == null || !sender.isTopLevel() || !"https".equals(Uri.parse(sender.url).getScheme()) ||
!"ncode.syosetu.com".equals(Uri.parse(sender.url).getHost()) || !(message instanceof JSONObject)) return null;
JSONObject data = (JSONObject) message;
if ("motion".equals(data.optString("type"))) {
// History restoration can race onPageStart's reading reset.
// Active reader polls carry their current state so it recovers.
if (data.optBoolean("enabled") && data.optBoolean("open")) {
reading = true;
menuButton.setVisibility(View.GONE);
}
return GeckoResult.fromValue(tilt.sample(
data.optBoolean("enabled") && reading && resumed && hasWindowFocus(),
data.optBoolean("moving")));
}
if ("language".equals(data.optString("type"))) {
languages.select(data.optString("language"));
prefs.edit().putString("readerLanguage", languages.language()).apply();
menuButton.setContentDescription(tr("native.menu"));
}
if ("menu".equals(data.optString("type"))) showMenu();
if ("theme".equals(data.optString("type"))) applyReaderTheme(data.optString("theme", "paper"));
if ("reader".equals(data.optString("type"))) {
reading = data.optBoolean("open");
if (!reading) tilt.stop();
menuButton.setVisibility(reading ? View.GONE : View.VISIBLE);
if (reading && !prefs.getBoolean("hintShown", false)) {
prefs.edit().putBoolean("hintShown", true).apply();
Toast.makeText(MainActivity.this, tr("native.focus.hint"), Toast.LENGTH_LONG).show();
}
}
return GeckoResult.fromValue(null);
}
}, "narou");
ready = true;
session.loadUri(pendingUrl);
}, error -> {
if (destroyed) return;
progress.setVisibility(View.GONE);
new AlertDialog.Builder(this).setTitle(tr("native.start.error"))
.setMessage(tr("native.extension.error") + error.getMessage())
.setPositiveButton(tr("native.close"), (d, w) -> finish()).show();
});
}
private static void flattenChoices(GeckoSession.PromptDelegate.ChoicePrompt.Choice[] input,
java.util.List<GeckoSession.PromptDelegate.ChoicePrompt.Choice> output) {
for (GeckoSession.PromptDelegate.ChoicePrompt.Choice choice : input) {
if (choice.disabled || choice.separator) continue;
if (choice.items != null) flattenChoices(choice.items, output);
else output.add(choice);
}
}
private void showMenu() {
if (destroyed || (menuDialog != null && menuDialog.isShowing())) return;
String[] actions = {tr("native.open.url"), tr("native.reload"), tr("native.share"), tr("native.browser"), tr("native.about")};
menuDialog = new AlertDialog.Builder(this).setTitle(tr("reader.name"))
.setItems(actions, (dialog, which) -> {
if (which == 0) openUrlDialog();
if (which == 1 && ready) session.reload();
if (which == 2) {
Intent share = new Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, currentUrl);
startActivity(Intent.createChooser(share, tr("native.share")));
}
if (which == 3) openExternal(currentUrl);
if (which == 4) new AlertDialog.Builder(this).setTitle(tr("reader.name") + " " + BuildConfig.VERSION_NAME)
.setMessage(tr("native.help"))
.setNeutralButton(tr("native.licenses"), (d, w) -> session.loadUri("about:license"))
.setPositiveButton(tr("native.ok"), null).show();
}).setNegativeButton(tr("native.continue"), null).create();
menuDialog.setOnDismissListener(d -> immersive());
menuDialog.show();
}
private void openUrlDialog() {
EditText field = new EditText(this);
field.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
field.setSingleLine(true); field.setText(currentUrl); field.selectAll();
int pad = dp(20);
FrameLayout wrap = new FrameLayout(this); wrap.setPadding(pad, dp(8), pad, 0);
wrap.addView(field, new FrameLayout.LayoutParams(-1, -2));
AlertDialog dialog = new AlertDialog.Builder(this).setTitle(tr("native.open.title"))
.setView(wrap).setPositiveButton(tr("native.open"), null).setNegativeButton(tr("native.cancel"), null).create();
dialog.setOnShowListener(d -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {
String url = field.getText().toString().trim();
if (url.startsWith("ncode.syosetu.com/")) url = "https://" + url;
if (!isInternal(url) || !"ncode.syosetu.com".equals(Uri.parse(url).getHost())) {
field.setError(tr("native.url.error")); return;
}
pendingUrl = url;
if (ready) session.loadUri(url);
dialog.dismiss();
}));
dialog.setOnDismissListener(d -> immersive());
dialog.show();
}
private static boolean isWeb(String url) {
if (url == null) return false;
Uri uri = Uri.parse(url);
return "https".equals(uri.getScheme()) && uri.getHost() != null && uri.getUserInfo() == null;
}
private static boolean isInternal(String url) {
if (!isWeb(url)) return false;
String host = Uri.parse(url).getHost();
return "syosetu.com".equals(host) || host.endsWith(".syosetu.com");
}
private String intentUrl(Intent intent) {
if (Intent.ACTION_VIEW.equals(intent.getAction())) {
String url = intent.getDataString();
return isInternal(url) ? url : null;
}
if (Intent.ACTION_SEND.equals(intent.getAction())) {
String shared = intent.getStringExtra(Intent.EXTRA_TEXT);
if (shared != null) {
Matcher match = SHARED_URL.matcher(shared);
if (match.find()) return match.group();
}
}
return null;
}
private void openExternal(String url) {
if (!isWeb(url)) return;
// A browser selector avoids routing our own ncode URL intent back into this app.
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
intent.setSelector(new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_APP_BROWSER));
try { startActivity(intent); }
catch (ActivityNotFoundException e) { Toast.makeText(this, tr("native.browser.error"), Toast.LENGTH_SHORT).show(); }
}
@Override protected void onNewIntent(Intent intent) {
super.onNewIntent(intent); setIntent(intent);
String url = intentUrl(intent);
if (url != null) { pendingUrl = url; if (ready) session.loadUri(url); }
}
private void navigateBack() {
if (session != null && canGoBack) session.goBack();
else finish();
}
@Override public void onBackPressed() { navigateBack(); }
@Override protected void onResume() { super.onResume(); resumed = true; if (session != null) session.setActive(true); immersive(); }
@Override protected void onPause() { resumed = false; tilt.stop(); if (session != null) session.setActive(false); super.onPause(); }
@Override public void onWindowFocusChanged(boolean focused) { super.onWindowFocusChanged(focused); if (focused) immersive(); else if (tilt != null) tilt.stop(); }
@Override protected void onDestroy() {
destroyed = true;
if (tilt != null) tilt.stop();
if (geckoView != null) geckoView.releaseSession();
if (session != null) session.close();
super.onDestroy();
}
/** Match the native cutout/safe-area background to the extension's palette. */
private void applyReaderTheme(String theme) {
if (!"dark".equals(theme) && !"white".equals(theme)) theme = "paper";
darkReaderTheme = "dark".equals(theme);
int background = darkReaderTheme ? Color.rgb(24, 32, 30)
: "white".equals(theme) ? Color.WHITE : Color.rgb(246, 240, 227);
root.setBackgroundColor(background);
getWindow().setBackgroundDrawable(new android.graphics.drawable.ColorDrawable(background));
if (!theme.equals(prefs.getString("readerTheme", "paper"))) {
prefs.edit().putString("readerTheme", theme).apply();
}
immersive();
}
private int dp(int value) { return Math.round(value * getResources().getDisplayMetrics().density); }
@SuppressWarnings("deprecation") private void immersive() {
if (Build.VERSION.SDK_INT >= 30) {
getWindow().setDecorFitsSystemWindows(false);
WindowInsetsController controller = getWindow().getInsetsController();
if (controller != null) {
int lightBars = WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
| WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS;
controller.setSystemBarsAppearance(darkReaderTheme ? 0 : lightBars, lightBars);
controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
controller.hide(WindowInsets.Type.systemBars());
}
} else {
getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY |
View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
(darkReaderTheme ? 0 : View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR));
}
}
}