app/src/main/java/net/astropenguin/naroureader/MainActivity.java
raw · 25246 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;
@Override public void onCreate(Bundle state) {
super.onCreate(state);
tilt = new TiltSensor(this);
prefs = getSharedPreferences("reader", MODE_PRIVATE);
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("Reader 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("Reader stopped")
.setMessage("The web engine stopped. Reopen this chapter to continue.")
.setPositiveButton("Reopen", (d, w) -> recreate()).setNegativeButton("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("Hex color, #RRGGBB");
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 = {"Red", "Green", "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("Text color").setView(scroll)
.setPositiveButton("Apply", null).setNegativeButton("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("Use #RRGGBB, for example #79dfb5"); 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 ? "Choose" : prompt.title)
.setSingleChoiceItems(labels, selected, (d, which) -> {
if (!prompt.isComplete()) result.complete(prompt.confirm(choices.get(which)));
d.dismiss();
}).setNegativeButton("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, "Could not load the page. Check your connection, then use Menu → Reload.", 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"))) {
return GeckoResult.fromValue(tilt.sample(
data.optBoolean("enabled") && reading && resumed && hasWindowFocus(),
data.optBoolean("moving")));
}
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, "Tap a paragraph to focus. Tap it again or empty space for controls.", 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("Reader could not start")
.setMessage("The built-in extension could not be loaded. Restart the app.\n\n" + error.getMessage())
.setPositiveButton("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 = {"Open novel URL…", "Reload", "Share chapter", "Open in browser", "About / help"};
menuDialog = new AlertDialog.Builder(this).setTitle("縦書き Reader")
.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, "Share chapter"));
}
if (which == 3) openExternal(currentUrl);
if (which == 4) new AlertDialog.Builder(this).setTitle("縦書き Reader " + BuildConfig.VERSION_NAME)
.setMessage("Swipe right to read forward. Tap a paragraph to focus. Tap it again or empty space for controls.\n\n文字: font size\n行間: column spacing\n段落: paragraph spacing\n配色: theme\nNormal / Focus text: saved text colors\n傾き: accelerometer scrolling; enable, set neutral, then return to reading\n自動: open reader automatically\n\nEmpty spacer paragraphs are removed. The last chapter and reading position are saved on this device.\n\nPowered by Mozilla GeckoView 158. No analytics. Website content and cookies stay in this app’s separate browser profile. GeckoView: MPL 2.0. Engine license details are in the supplied source package.")
.setNeutralButton("Engine licenses", (d, w) -> session.loadUri("about:license"))
.setPositiveButton("OK", null).show();
}).setNegativeButton("Continue reading", 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("Open a Narou novel")
.setView(wrap).setPositiveButton("Open", null).setNegativeButton("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("Use an https://ncode.syosetu.com/ URL"); 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, "No browser is available.", 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));
}
}
}