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 darkReaderBackground; 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); applyReaderBackground(prefs.getString("readerBackgroundColor", "#e3e3df")); 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 onColorPrompt(GeckoSession s, ColorPrompt prompt) { GeckoResult 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 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 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 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 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 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 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 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 ("background".equals(data.optString("type"))) applyReaderBackground(data.optString("color")); 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 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 reader's actual color. */ private void applyReaderBackground(String color) { if (color == null || !color.matches("#[0-9a-fA-F]{6}")) return; int background = Color.parseColor(color); darkReaderBackground = (299 * Color.red(background) + 587 * Color.green(background) + 114 * Color.blue(background)) < 128000; root.setBackgroundColor(background); getWindow().setBackgroundDrawable(new android.graphics.drawable.ColorDrawable(background)); if (!color.equals(prefs.getString("readerBackgroundColor", ""))) { prefs.edit().putString("readerBackgroundColor", color).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(darkReaderBackground ? 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 | (darkReaderBackground ? 0 : View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR)); } } }