com.google.code.twisty.Twisty.java Source code

Java tutorial

Introduction

Here is the source code for com.google.code.twisty.Twisty.java

Source

// Copyright 2009 Google Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.

package com.google.code.twisty;

// Note to developers: this Android activity is essentially a high-level consumer
// of the 'glkjni' project, which maps the classic GLK I/O API (used by game-
// interpreters to do UI) to JNI.  This allows us to run well-known C interpreters 
// as a native C library for maximum performance.  In particular, to build
// this project you'll need to get a copy of the glkjni code and have Eclipse link
// the 'roboglk' directory into your twisty project.  See the README file for a full
// explanation of how to build both the C and java code in this project.

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.ref.WeakReference;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Arrays;

import org.brickshadow.roboglk.Glk;
import org.brickshadow.roboglk.GlkFactory;
import org.brickshadow.roboglk.GlkLayout;
import org.brickshadow.roboglk.GlkStyle;
import org.brickshadow.roboglk.GlkStyleHint;
import org.brickshadow.roboglk.GlkWinType;
import org.brickshadow.roboglk.io.StyleManager;
import org.brickshadow.roboglk.io.TextBufferIO;
import org.brickshadow.roboglk.util.UISync;
import org.brickshadow.roboglk.view.TextBufferView;

import android.Manifest;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.app.ProgressDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.graphics.Color;
import android.net.Uri;
import android.os.BatteryManager;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Message;
import android.preference.PreferenceManager;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.SparseArray;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.Window;
import android.view.ViewGroup.LayoutParams;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Gallery;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.RadioGroup;
import android.widget.RadioButton;
import android.widget.TextView;
import android.widget.RadioGroup.OnCheckedChangeListener;

import com.jakewharton.processphoenix.ProcessPhoenix;

public class Twisty extends Activity {
    private static String TAG = "Twisty";
    private static final int MENU_PICK_FILE = 101;
    private static final int MENU_STOP = 102;
    private static final int MENU_RESTART = 103;
    private static final int FILE_PICKED = 104;
    private static final int MENU_SHOW_HELP = 107;
    private static final int MENU_PICK_SETTINGS = 108;

    private static final int MENUGROUP_SELECT = 101;
    private static final int MENUGROUP_RUNNING = 102;

    String savegame_dir = "";
    String savefile_path = "";

    // Dialog boxes we manage
    private static final int DIALOG_ENTER_WRITEFILE = 1;
    private static final int DIALOG_ENTER_READFILE = 2;
    private static final int DIALOG_CHOOSE_GAME = 3;
    private static final int DIALOG_CANT_SAVE = 4;
    private static final int DIALOG_NO_SDCARD = 5;

    // Messages we receive from external threads, via our Handler
    public static final int PROMPT_FOR_WRITEFILE = 1;
    public static final int PROMPT_FOR_READFILE = 2;
    public static final int PROMPT_FOR_GAME = 3;

    // Permission request identifiers
    private final int MY_PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE = 1;

    // Regular expression that matches all the file extensions we support
    public static final String EXTENSIONS = "(?i).+\\.(z[1-8]|blorb|zblorb|gblorb|ulx|blb|zlb|glb)$";

    // Regular expressions that match the file extensions supported by each interpreter
    public static final String NITFOL_EXTENSIONS = "(?i).+\\.(z[1-8]|zblorb|zlb)$";
    public static final String GIT_EXTENSIONS = "(?i).+\\.(blorb|gblorb|ulx|blb|glb)$";

    // The main GLK UI machinery.
    Glk glk;
    GlkLayout glkLayout;
    TextBufferIO mainWin;
    ImageView iv;
    TextBufferView tv;
    LinearLayout ll;
    Thread terpThread = null;
    String gamePath;
    Boolean gameIsRunning = false;

    // Passed down to TwistyGlk object, so terp thread can send Messages back to this thread
    private Handler dialog_handler;
    private Handler terp_handler;
    private TwistyMessage dialog_message; // most recent Message received
    // Persistent dialogs created in onCreateDialog() and updated by onPrepareDialog()
    private Dialog restoredialog;
    private Dialog choosegamedialog;
    // All z-games discovered when we last scanned the sdcard
    private String[] discovered_games;
    // A persistent map of button-ids to games found on the sdcard (absolute paths)
    private SparseArray<String> game_paths = new SparseArray<String>();
    private SparseArray<String> builtinGames = new SparseArray<String>();

    static class DialogHandler extends Handler {
        final WeakReference<Twisty> twisty;

        DialogHandler(Twisty twisty) {
            this.twisty = new WeakReference<Twisty>(twisty);
        }

        public void handleMessage(Message m) {
            twisty.get().savegame_dir = "";
            twisty.get().savefile_path = "";
            if (m.what == PROMPT_FOR_WRITEFILE) {
                twisty.get().dialog_message = (TwistyMessage) m.obj;
                twisty.get().promptForWritefile();
            } else if (m.what == PROMPT_FOR_READFILE) {
                twisty.get().dialog_message = (TwistyMessage) m.obj;
                twisty.get().promptForReadfile();
            } else if (m.what == PROMPT_FOR_GAME) {
                twisty.get().showDialog(DIALOG_CHOOSE_GAME);
            }
        }
    }

    static class TerpHandler extends Handler {
        final WeakReference<Twisty> twisty;

        TerpHandler(Twisty twisty) {
            this.twisty = new WeakReference<Twisty>(twisty);
        }

        public void handleMessage(Message m) {
            switch (m.arg1) {
            case -1:
                Log.i("twistyterp", "The interpreter did not start");
                break;
            case 0:
                Log.i("twistyterp", "The interpreter exited normally");
                break;
            case 1:
                Log.i("twistyterp", "The interpreter exited abnormally");
                break;
            case 2:
                Log.i("twistyterp", "The interpreter was interrupted");
                break;
            }
            twisty.get().showWelcome();
            twisty.get().gameIsRunning = false;
        }
    }

    /** Called when activity is first created. */
    @Override
    public void onCreate(Bundle icicle) {
        super.onCreate(icicle);
        // requestWindowFeature(Window.FEATURE_NO_TITLE);
        requestWindowFeature(Window.FEATURE_ACTION_BAR);

        // Map our built-in game resources to real filenames
        builtinGames.clear();
        builtinGames.put(R.raw.violet, "violet.z8");
        builtinGames.put(R.raw.rover, "rover.gblorb");
        builtinGames.put(R.raw.glulxercise, "glulxercise.ulx");
        builtinGames.put(R.raw.windowtest, "windowtest.ulx");

        UISync.setInstance(this);

        // An imageview to show the twisty icon
        iv = new ImageView(this);
        iv.setBackgroundColor(0xffffff);
        iv.setImageResource(R.drawable.app_icon);
        iv.setAdjustViewBounds(true);
        iv.setLayoutParams(new Gallery.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT));

        // The main 'welcome screen' window from which games are launched.
        tv = new TextBufferView(this);
        mainWin = new TextBufferIO(tv, new StyleManager());
        //final GlkEventQueue eventQueue = null;
        tv.setFocusableInTouchMode(true);

        // The Glk window layout manager
        glkLayout = new GlkLayout(this);

        // put it all together
        ll = new LinearLayout(this);
        ll.setBackgroundColor(Color.argb(0xFF, 0xFE, 0xFF, 0xCC));
        ll.setOrientation(android.widget.LinearLayout.VERTICAL);
        ll.addView(iv);
        ll.addView(tv);
        glkLayout.setVisibility(View.GONE);
        ll.addView(glkLayout);
        setContentView(ll);

        dialog_handler = new DialogHandler(this);
        terp_handler = new TerpHandler(this);

        // Ensure we can write story files and save games to external storage
        checkWritePermission();

        Uri dataSource = this.getIntent().getData();
        if (dataSource != null) {
            /* Suck down the URI we received to sdcard, launch terp on it. */
            try {
                startTerp(dataSource);
            } catch (Exception e) {
                Log.e(TAG, e.getMessage(), e);
            }
        } else {
            printWelcomeMessage();
        }
    }

    /** Called whenever activity comes (or returns) to foreground. */
    @Override
    public void onStart() {
        super.onStart();
        getSettings(); // make sure user prefs are applied
    }

    private void printWelcomeMessage() {
        // What version of Twisty is running?
        PackageInfo pkginfo = null;
        try {
            pkginfo = this.getPackageManager().getPackageInfo("com.google.code.twisty", 0);
        } catch (PackageManager.NameNotFoundException e) {
            Log.e(TAG, "Couldn't determine Twisty version.");
        }

        StringBuffer battstate = new StringBuffer();
        appendBatteryState(battstate);

        mainWin.doStyle(GlkStyle.Normal);

        mainWin.doReverseVideo(true);
        mainWin.doPrint("Twisty " + pkginfo.versionName + "\nOpen-source software (C) Google Inc.\n\n\n");
        mainWin.doReverseVideo(false);
        mainWin.doPrint("You are holding a modern-looking mobile device which can be typed upon. ");
        mainWin.doPrint(battstate.toString() + " ");
        mainWin.doPrint("You feel an inexplicable urge to press the \"menu\" button.\n\n");
    }

    /* TODO:  rewrite this someday
        
    private void printHelpMessage() {
    if (zmIsRunning()) {
        Log.e(TAG, "Called printHelpMessage with ZM running");
        return;
    }
        
    ZWindow w = new ZWindow(screen, 0);
    w.set_text_style(ZWindow.ROMAN);
    w.newline();
    w.newline();
    w.bufferString("-------------------------------------");
    w.newline();
    w.bufferString("Concepts stream into your mind:");
    w.newline();
    w.newline();
    w.bufferString("Interactive Fiction (IF) is its own genre of game: an artful crossing ");
    w.bufferString("of storytelling and puzzle-solving. ");
    w.bufferString("Read the Wikipedia entry on 'Interactive ");
    w.bufferString("Fiction' to learn more.");
    w.newline();
    w.newline();
    w.bufferString("You are the protagonist of the story. Your job is to explore the ");
    w.bufferString("environment, interact with people and things, solve puzzles, and move ");
    w.bufferString("the story forward.  The interpreter is limited to a small set of ");
    w.bufferString("vocabulary, typically of the form 'verb noun'.  To get started:");
    w.newline();
    w.newline();
    w.bufferString("  * north, east, up, down, enter...");
    w.newline();
    w.bufferString("  * look, look under rug, examine pen");
    w.newline();
    w.bufferString("  * take ball, drop hat, inventory");
    w.newline();
    w.bufferString("  * Janice, tell me about the woodshed");
    w.newline();
    w.bufferString("  * save, restore");
    w.newline();
    w.newline();
    w.bufferString("For a more detailed tutorial, type 'help' inside the Curses or Anchorhead games.");
    w.newline();
    w.newline();
    w.bufferString("Twisty comes with three built-in games, but if you visit sites like ");
    w.bufferString("www.ifarchive.org or ifdb.tads.org, you can download ");
    w.bufferString("more games that end with either .z3, .z5, or .z8, copy them to your sdcard, then open them in Twisty.");
    w.newline();
    w.newline();
    w.flush();
    } */

    private void appendBatteryState(StringBuffer sb) {
        IntentFilter battFilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
        Intent intent = registerReceiver(null, battFilter);

        int rawlevel = intent.getIntExtra("level", -1);
        int scale = intent.getIntExtra("scale", -1);
        int status = intent.getIntExtra("status", -1);
        int health = intent.getIntExtra("health", -1);
        int level = -1; // percentage, or -1 for unknown
        if (rawlevel >= 0 && scale > 0) {
            level = (rawlevel * 100) / scale;
        }
        sb.append("The device");
        if (BatteryManager.BATTERY_HEALTH_OVERHEAT == health) {
            sb.append("'s battery feels very hot!");
        } else {
            switch (status) {
            case BatteryManager.BATTERY_STATUS_UNKNOWN:
                // old emulator; maybe also when plugged in with no battery
                sb.append(" has no battery.");
                break;
            case BatteryManager.BATTERY_STATUS_CHARGING:
                sb.append("'s battery");
                if (level <= 33)
                    sb.append(" is charging, and really ought to " + "remain that way for the time being.");
                else if (level <= 84)
                    sb.append(" charges merrily.");
                else
                    sb.append(" will soon be fully charged.");
                break;
            case BatteryManager.BATTERY_STATUS_DISCHARGING:
            case BatteryManager.BATTERY_STATUS_NOT_CHARGING:
                if (level == 0)
                    sb.append(" needs charging right away.");
                else if (level > 0 && level <= 33)
                    sb.append(" is about ready to be recharged.");
                else
                    sb.append("'s battery discharges merrily.");
                break;
            case BatteryManager.BATTERY_STATUS_FULL:
                sb.append(" is fully charged up and ready to go on " + "an adventure of some sort.");
                break;
            default:
                sb.append("'s battery is indescribable!");
                break;
            }
        }
        sb.append(" ");
    }

    protected void onActivityResult(int requestCode, int resultCode, String data, Bundle extras) {
        switch (requestCode) {
        case FILE_PICKED:
            if (resultCode == RESULT_OK && data != null) {
                // File picker completed
                Log.i(TAG, "Opening user-picked file: " + data);
                startTerp(data);
            }
            break;
        default:
            break;
        }
    }

    /** convenience helper to set text on a text view */
    void setItemText(final int id, final CharSequence text) {
        View v = findViewById(id);
        if (v instanceof TextView) {
            TextView tv = (TextView) v;
            tv.setText(text);
        }
    }

    // Show the GlkLayout and hide the welcome screen
    void showGlk() {
        iv.setVisibility(View.GONE);
        tv.setVisibility(View.GONE);
        glkLayout.setVisibility(View.VISIBLE);
    }

    // Show the welcome screen and hide the GlkLayout
    void showWelcome() {
        iv.setVisibility(View.VISIBLE);
        tv.setVisibility(View.VISIBLE);
        glkLayout.setVisibility(View.GONE);
    }

    /**
     * Start a terp thread, loading the program from the given game file
     * @param path Path to the gamefile to execute
     */
    void startTerp(String path) {

        // Notice user preferences
        //Context context = getApplicationContext();
        //SharedPreferences prefs =
        //   PreferenceManager.getDefaultSharedPreferences(context);
        //Log.i(TAG, "Spies tell me that ");

        // Switch to the Glk view
        showGlk();

        gamePath = path;

        // Make a GLK object which encapsulates I/O between Android UI and our C library
        glk = new TwistyGlk(this, glkLayout, dialog_handler);
        glk.setStyleHint(GlkWinType.AllTypes, GlkStyle.Normal, GlkStyleHint.Size, -2);
        terpThread = new Thread(new Runnable() {
            @Override
            public void run() {
                String interpreter = "";
                if (gamePath.matches(NITFOL_EXTENSIONS))
                    interpreter = "nitfol";
                else if (gamePath.matches(GIT_EXTENSIONS))
                    interpreter = "git";

                String[] args = new String[] { interpreter, gamePath };
                int res = -1;
                if (GlkFactory.startup(glk, args)) {
                    res = GlkFactory.run();
                }
                GlkFactory.shutdown();
                Message m = terp_handler.obtainMessage();
                m.arg1 = res;
                terp_handler.sendMessage(m);

                // Restarts the app. This ensures the interpreter libraries are reloaded. If we
                // don't do this, GlkFactory.shutdown needs to assure for every library we
                // integrate that its internal state is reset so that it behaves exactly the
                // same as if it were started fresh, preferable without leaking memory. Since
                // Glk doesn't specify this functionality, this means we'd need to modify each
                // library to accommodate this. Reloading the library avoids this issue.
                ProcessPhoenix.triggerRebirth(Twisty.this);
            }
        });
        terpThread.start();
        gameIsRunning = true;
    }

    /* Starts one of the 'built in' games from an android raw resource.
       It does this by dumping the resource into /sdcard/Twisty/ (if not already there.) */
    void startTerp(int resource) {

        String gameName = builtinGames.get(resource); // go-go-autoboxing
        if (gameName == null) {
            Log.i(TAG, "Failed to find built-in game resource" + resource);
            return;
        }
        Log.i(TAG, "Loading game resource: " + gameName);

        String savedGamesDir = getSavedGamesDir(true);
        if (savedGamesDir == null) {
            showDialog(DIALOG_CANT_SAVE);
            return;
        }

        // The absolute disk path to the privately-stored gamefile:
        File gameFile = new File(savedGamesDir + "/" + gameName);
        String gamePath = gameFile.getAbsolutePath();
        Log.i(TAG, "Looking for gamefile at " + gamePath);

        if (!gameFile.exists()) {
            // Do a one-time dump from raw resource to disk.
            FileOutputStream foutstream;
            Resources r = new Resources(getAssets(), new DisplayMetrics(), null);
            InputStream gamestream = r.openRawResource(resource);
            try {
                foutstream = new FileOutputStream(gameFile);
            } catch (IOException e) {
                Log.i(TAG, "Failed to open outputstream for filename " + gamePath);
                Log.i(TAG, e.getMessage());
                return;
            }
            try {
                Log.i(TAG, "About to spew raw data to disk...");
                suckstream(gamestream, foutstream);
            } catch (IOException e) {
                Log.i(TAG, "Failed to copy raw game to file." + gamePath);
                return;
            }
            Log.i(TAG, "Completed dump of raw data to disk.");
        }

        Log.i(TAG, "Starting gamefile located at " + gamePath);
        startTerp(gamePath);
    }

    /* Starts a game located at a URI (usually http:// or content://) by downloading the game to sdcard first.
       This is the method invoked by our IntentFilter to handle story files coming from the web browser
       and other applications. */
    void startTerp(Uri gameURI) throws IOException, MalformedURLException {

        /* Set up output file in same directory as saved-games. */
        String dir = getSavedGamesDir(true);
        if (dir == null) {
            showDialog(DIALOG_CANT_SAVE);
            return;
        }

        String uriString = gameURI.toString();
        String gameFilename = uriString.substring(uriString.lastIndexOf("/") + 1);
        File outputFile = new File(dir, gameFilename);

        // Copy the input file into our story directory, unless the input and output file
        // would be the same. This check currently doesn't support uri's that don't contain
        // the file path. In that case the file will be corrupted.
        if (!((gameURI.getScheme().equals("content") || gameURI.getScheme().equals("file"))
                && gameURI.toString().contains(outputFile.getCanonicalPath()))) {
            FileOutputStream gameOutputStream = null;
            try {
                outputFile.createNewFile();
                gameOutputStream = new FileOutputStream(outputFile);
            } catch (IOException e) {
                Log.i(TAG, "Failed to create file called " + gameFilename);
                return;
            }

            /* Set up input from URI */
            InputStream gameInputStream = null;

            if (gameURI.getScheme().equals("content")) {
                try {
                    gameInputStream = getContentResolver().openInputStream(gameURI);
                } catch (FileNotFoundException e) {
                    Log.i(TAG, "Failed to open file: " + uriString);
                    gameOutputStream.close();
                    return;
                }
            } else {
                try {
                    URL gameURL = new URL(uriString);
                    URLConnection connection = gameURL.openConnection();
                    connection.connect();
                    gameInputStream = connection.getInputStream();
                } catch (MalformedURLException e) {
                    Log.i(TAG, "Received malformed URI: " + uriString);
                    gameOutputStream.close();
                    return;
                } catch (IOException e) {
                    Log.i(TAG, "Failed to open connection to URI: " + uriString);
                    gameOutputStream.close();
                    return;
                }
            }

            try {
                Log.i(TAG, "About to spew raw data to disk...");
                suckstream(gameInputStream, gameOutputStream);
            } catch (IOException e) {
                Log.i(TAG, "Failed to copy URL contents to local file: " + uriString);
                return;
            }
            Log.i(TAG, "Completed dump of raw data to disk.");
        } else
            Log.i(TAG, "Input and output file are the same: " + outputFile.getCanonicalPath());

        Log.i(TAG, "Starting gamefile located at " + outputFile.getCanonicalPath());
        startTerp(outputFile.getCanonicalPath());
    }

    /* Helper for methods above:  suck all data from an InputStream and push to an OutputStream */
    void suckstream(InputStream instream, OutputStream outstream) throws IOException {
        int got = 0, buffersize = 65536;
        byte buffer[] = new byte[buffersize];
        while (true) {
            got = instream.read(buffer, 0, buffersize);
            if (got == -1)
                break; // got no data; end of stream
            outstream.write(buffer, 0, got);
        }
    }

    /** Convenience helper to set visibility of any view */
    void setViewVisibility(int id, int vis) {
        findViewById(id).setVisibility(vis);
    }

    private boolean terpIsRunning() {
        Log.i(TAG, "Query whether game is running!");
        return gameIsRunning;
    }

    /** Stops the currently running interpreter. */
    public void stopTerp() {
        if (terpThread != null) {
            terpThread.interrupt();
            Log.i(TAG, "Interrupted terpThread.");
        }
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        menu.add(MENUGROUP_SELECT, R.raw.violet, 0, "Violet").setShortcut('0', 'a');
        menu.add(MENUGROUP_SELECT, R.raw.rover, 1, "Rover").setShortcut('1', 'b');
        menu.add(MENUGROUP_SELECT, R.raw.glulxercise, 2, "glulxercise").setShortcut('2', 'c');
        menu.add(MENUGROUP_SELECT, R.raw.windowtest, 2, "windowtest").setShortcut('6', 'd');
        menu.add(MENUGROUP_SELECT, MENU_PICK_FILE, 3, "Open Game...").setShortcut('3', 'o');
        //menu.add(MENUGROUP_SELECT, MENU_SHOW_HELP, 5, "Help!?").setShortcut('4', 'h');
        menu.add(MENUGROUP_SELECT, MENU_PICK_SETTINGS, 5, "Settings").setShortcut('5', 's');

        //menu.add(MENUGROUP_RUNNING, MENU_RESTART, 0, "Restart").setShortcut('7', 'r');
        menu.add(MENUGROUP_RUNNING, MENU_STOP, 1, "Stop").setShortcut('9', 's');
        menu.add(MENUGROUP_RUNNING, MENU_PICK_SETTINGS, 2, "Settings").setShortcut('4', 's');

        return super.onCreateOptionsMenu(menu);
    }

    @Override
    public boolean onPrepareOptionsMenu(Menu menu) {
        menu.setGroupVisible(MENUGROUP_SELECT, !terpIsRunning());
        menu.setGroupVisible(MENUGROUP_RUNNING, terpIsRunning());
        return super.onPrepareOptionsMenu(menu);
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
        case MENU_RESTART:
            // TODO:  zm.restart();
            break;
        case MENU_STOP:
            stopTerp();
            // After the zmachine exits, the welcome message should show
            // again.
            break;
        case MENU_PICK_FILE:
            pickFile();
            break;
        case MENU_PICK_SETTINGS:
            pickSettings();
            break;
        case MENU_SHOW_HELP:
            // TODO:  printHelpMessage();
            break;
        default:
            startTerp(item.getItemId());
            break;
        }
        return super.onOptionsItemSelected(item);
    }

    /** Launch UI to adjust application settings **/
    private void pickSettings() {
        Intent settingsActivity = new Intent(getBaseContext(), TwistyPreferenceActivity.class);
        startActivity(settingsActivity);
    }

    /** Called by onStart(), to make sure prefs are loaded whenever we return
     *  to the main Twisty activity (e.g. after backing out of the
     *  Preferences activity)
     */
    private void getSettings() {
        SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getBaseContext());
        String textSizePreference = prefs.getString("PREF_TEXT_SIZE", "14");
        float textsize = Float.valueOf(textSizePreference).floatValue();
        tv.setTextSize(textsize);
    }

    /** Launch UI to pick a file to load and execute */
    private void pickFile() {
        String storagestate = Environment.getExternalStorageState();
        if (storagestate.equals(Environment.MEDIA_MOUNTED)
                || storagestate.equals(Environment.MEDIA_MOUNTED_READ_ONLY)) {
            final ProgressDialog pd = ProgressDialog.show(Twisty.this, "Scanning Media", "Searching for Games...",
                    true);
            Thread t = new Thread() {
                public void run() {
                    // populate our list of games:
                    discovered_games = scanForGames();
                    pd.dismiss();
                    Message msg = new Message();
                    msg.what = PROMPT_FOR_GAME;
                    dialog_handler.sendMessage(msg);
                }
            };
            t.start();
        } else
            showDialog(DIALOG_NO_SDCARD); // no sdcard to scan
    }

    // Return the path to the saved-games directory (typically "/sdcard/Twisty/")
    // If sdcard not present, or if /sdcard/Twisty is a file, return null.
    public static String getSavedGamesDir(boolean write) {
        String storagestate = Environment.getExternalStorageState();
        if (!storagestate.equals(Environment.MEDIA_MOUNTED)
                && (write || storagestate.equals(Environment.MEDIA_MOUNTED_READ_ONLY))) {
            return null;
        }
        String sdpath = Environment.getExternalStorageDirectory().getPath();
        File savedir = new File(sdpath + "/" + TAG);
        if (!savedir.exists()) {
            savedir.mkdirs();
        } else if (!savedir.isDirectory()) {
            return null;
        }
        return savedir.getPath();
    }

    // Helper for scanForGames():
    // Walk DIR recursively, adding any file matching the expression in EXTENSIONS to LIST.
    private void scanDir(File dir, ArrayList<String> list) {
        File[] children = dir.listFiles();
        if (children == null)
            return;
        for (int count = 0; count < children.length; count++) {
            File child = children[count];
            if (child.isFile() && child.getName().matches(EXTENSIONS))
                list.add(child.getPath());
            else
                scanDir(child, list);
        }
    }

    // Search the twisty directory (on sdcard) for any games.
    // Return an array of absolute paths, or null on failure.
    private String[] scanForGames() {
        String gamesDirPath = getSavedGamesDir(false);
        if (gamesDirPath == null) {
            showDialog(DIALOG_CANT_SAVE);
            return null;
        }
        File gameDirRoot = new File(gamesDirPath);
        ArrayList<String> gamelist = new ArrayList<String>();
        scanDir(gameDirRoot, gamelist);
        String[] files = gamelist.toArray(new String[gamelist.size()]);
        Arrays.sort(files);
        return files;
    }

    public void promptForWritefile() {
        String dir = getSavedGamesDir(true);
        if (dir == null) {
            showDialog(DIALOG_CANT_SAVE);
            return;
        }
        savegame_dir = dir;
        showDialog(DIALOG_ENTER_WRITEFILE);
    }

    private void promptForReadfile() {
        String dir = getSavedGamesDir(false);
        if (dir == null) {
            showDialog(DIALOG_CANT_SAVE);
            return;
        }
        savegame_dir = dir;
        showDialog(DIALOG_ENTER_READFILE);
    }

    // Used by 'Restore Game' dialog box;  scans /sdcard/twisty and updates
    // the list of radiobuttons.
    private void updateRestoreRadioButtons(RadioGroup rg) {
        rg.removeAllViews();
        int id = 0;
        String[] gamelist = new File(savegame_dir).list();
        for (String filename : gamelist) {
            RadioButton rb = new RadioButton(Twisty.this);
            rb.setText(filename);
            rg.addView(rb);
            id = rb.getId();
        }
        rg.check(id); // by default, check the last item
    }

    // Used by 'Choose Game' dialog box;  scans all of /sdcard for games,
    // updates list of radiobuttons (and the game_paths HashMap).
    private void updateGameRadioButtons(RadioGroup rg) {
        rg.removeAllViews();
        game_paths.clear();
        int id = 0;
        for (String path : discovered_games) {
            RadioButton rb = new RadioButton(Twisty.this);
            rb.setText(new File(path).getName());
            rg.addView(rb);
            id = rb.getId();
            game_paths.put(id, path);
        }
    }

    /** Have our activity manage and persist dialogs, showing and hiding them */
    @Override
    protected Dialog onCreateDialog(int id) {
        switch (id) {

        case DIALOG_ENTER_WRITEFILE:
            LayoutInflater factory = LayoutInflater.from(this);
            final View textEntryView = factory.inflate(R.layout.save_file_prompt, null);
            final EditText et = (EditText) textEntryView.findViewById(R.id.savefile_entry);
            return new AlertDialog.Builder(Twisty.this).setTitle("Write to file").setView(textEntryView)
                    .setPositiveButton("OK", new DialogInterface.OnClickListener() {
                        public void onClick(DialogInterface dialog, int whichButton) {
                            savefile_path = savegame_dir + "/" + et.getText().toString();
                            // Directly modify the message-object passed to us by the terp thread:
                            dialog_message.path = savefile_path;
                            // Wake up the terp thread again
                            synchronized (glkLayout) {
                                glkLayout.notify();
                            }
                        }
                    }).setNegativeButton("Cancel", new DialogInterface.OnClickListener() {
                        public void onClick(DialogInterface dialog, int whichButton) {
                            // This makes op_save() fail.
                            dialog_message.path = "";
                            // Wake up the terp thread again
                            synchronized (glkLayout) {
                                glkLayout.notify();
                            }
                        }
                    }).create();

        case DIALOG_ENTER_READFILE:
            restoredialog = new Dialog(Twisty.this);
            restoredialog.setContentView(R.layout.restore_file_prompt);
            restoredialog.setTitle("Read a file");
            android.widget.RadioGroup rg = (RadioGroup) restoredialog.findViewById(R.id.radiomenu);
            updateRestoreRadioButtons(rg);
            android.widget.Button okbutton = (Button) restoredialog.findViewById(R.id.restoreokbutton);
            okbutton.setOnClickListener(new View.OnClickListener() {
                public void onClick(View v) {
                    android.widget.RadioGroup rg = (RadioGroup) restoredialog.findViewById(R.id.radiomenu);
                    int checkedid = rg.getCheckedRadioButtonId();
                    if (rg.getChildCount() == 0) { // no saved games:  FAIL
                        savefile_path = "";
                    } else if (checkedid == -1) { // no game selected
                        RadioButton firstbutton = (RadioButton) rg.getChildAt(0); // default to first game
                        savefile_path = savegame_dir + "/" + firstbutton.getText();
                    } else {
                        RadioButton checkedbutton = (RadioButton) rg.findViewById(checkedid);
                        savefile_path = savegame_dir + "/" + checkedbutton.getText();
                    }
                    dismissDialog(DIALOG_ENTER_READFILE);
                    // Return control to the z-machine thread
                    dialog_message.path = savefile_path;
                    synchronized (glkLayout) {
                        glkLayout.notify();
                    }
                }
            });
            android.widget.Button cancelbutton = (Button) restoredialog.findViewById(R.id.restorecancelbutton);
            cancelbutton.setOnClickListener(new View.OnClickListener() {
                public void onClick(View v) {
                    dismissDialog(DIALOG_ENTER_READFILE);
                    // Return control to the z-machine thread
                    dialog_message.path = "";
                    synchronized (glkLayout) {
                        glkLayout.notify();
                    }
                }
            });
            return restoredialog;

        case DIALOG_CHOOSE_GAME:
            choosegamedialog = new Dialog(Twisty.this);
            choosegamedialog.setContentView(R.layout.choose_game_prompt);
            choosegamedialog.setTitle("Choose Game");
            android.widget.RadioGroup zrg = (RadioGroup) choosegamedialog.findViewById(R.id.game_radiomenu);
            updateGameRadioButtons(zrg);
            zrg.setOnCheckedChangeListener(new OnCheckedChangeListener() {
                public void onCheckedChanged(RadioGroup group, int checkedId) {
                    dismissDialog(DIALOG_CHOOSE_GAME);
                    String path = (String) game_paths.get(checkedId);
                    if (path != null) {
                        stopTerp();
                        startTerp(path);
                    }
                }
            });
            return choosegamedialog;

        case DIALOG_CANT_SAVE:
            return new AlertDialog.Builder(Twisty.this).setTitle("Cannot Access Games")
                    .setMessage("Twisty Games folder is not available on external media.")
                    .setPositiveButton("OK", new DialogInterface.OnClickListener() {
                        public void onClick(DialogInterface dialog, int whichButton) {
                            // A path of "" makes op_save() fail.
                            dialog_message.path = "";
                            // Wake up the terp thread again
                            synchronized (glkLayout) {
                                glkLayout.notify();
                            }
                        }
                    }).create();

        case DIALOG_NO_SDCARD:
            return new AlertDialog.Builder(Twisty.this).setTitle("No External Media")
                    .setMessage("Cannot find sdcard or other media.")
                    .setPositiveButton("OK", new DialogInterface.OnClickListener() {
                        public void onClick(DialogInterface dialog, int whichButton) {
                            // do nothing
                        }
                    }).create();
        }
        return null;
    }

    /** Have our activity prepare dialogs before displaying them */
    @Override
    protected void onPrepareDialog(int id, Dialog dialog) {
        super.onPrepareDialog(id, dialog);
        switch (id) {
        case DIALOG_ENTER_READFILE:
            android.widget.RadioGroup rg = (RadioGroup) restoredialog.findViewById(R.id.radiomenu);
            updateRestoreRadioButtons(rg);
            break;
        case DIALOG_CHOOSE_GAME:
            android.widget.RadioGroup zrg = (RadioGroup) choosegamedialog.findViewById(R.id.game_radiomenu);
            updateGameRadioButtons(zrg);
            break;
        }
    }

    public void showMore(boolean show) {
        setViewVisibility(R.id.more, show ? View.VISIBLE : View.GONE);
    }

    /*
        @Override
        protected void onRestoreInstanceState(Bundle savedInstanceState) {
    // Restore saved view state
    super.onRestoreInstanceState(savedInstanceState);
    // TODO: restore zmachine state
        }
    */

    @Override
    protected void onSaveInstanceState(Bundle bundle) {
        // Hopefully this will save all the view states:
        super.onSaveInstanceState(bundle);
    }

    /*
        private boolean unfreezeZM(Bundle icicle) {
    if (icicle == null)
        return false;
    Log.i(TAG, "unfreeze: finished");
    return true;
        }
    */

    void checkWritePermission() {
        if (ContextCompat.checkSelfPermission(this,
                Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(this, new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE },
                    MY_PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE);
        }
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) {
        switch (requestCode) {
        case MY_PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE: {
            if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                Log.i(TAG, "Write permission granted");
            } else {
                // TODO: do something more user-friendly here. Twisty will currently silently
                // fail on operations that require the permission when it isn't granted.
                Log.i(TAG, "User did not grant write permission");
            }
            break;
        }
        }
    }
}