penguin/narou-android

Simple 縦書き reader for narou

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));
        }
    }
}