Home | History | Annotate | Download | only in activity
      1 /*
      2  * Copyright (C) 2016 Google Inc.
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License"); you may not
      5  * use this file except in compliance with the License. You may obtain a copy of
      6  * the License at
      7  *
      8  * http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
     12  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
     13  * License for the specific language governing permissions and limitations under
     14  * the License.
     15  */
     16 
     17 package com.googlecode.android_scripting.activity;
     18 
     19 import android.app.AlertDialog;
     20 import android.app.ListActivity;
     21 import android.app.SearchManager;
     22 import android.content.ActivityNotFoundException;
     23 import android.content.Context;
     24 import android.content.DialogInterface;
     25 import android.content.Intent;
     26 import android.content.SharedPreferences;
     27 import android.database.DataSetObserver;
     28 import android.net.Uri;
     29 import android.os.Bundle;
     30 import android.os.Handler;
     31 import android.preference.PreferenceManager;
     32 import android.view.ContextMenu;
     33 import android.view.ContextMenu.ContextMenuInfo;
     34 import android.view.KeyEvent;
     35 import android.view.Menu;
     36 import android.view.MenuItem;
     37 import android.view.View;
     38 import android.view.View.OnClickListener;
     39 import android.widget.AdapterView;
     40 import android.widget.EditText;
     41 import android.widget.ListView;
     42 import android.widget.TextView;
     43 import android.widget.Toast;
     44 
     45 import com.google.common.base.Predicate;
     46 import com.google.common.collect.Collections2;
     47 import com.google.common.collect.Lists;
     48 import com.googlecode.android_scripting.ActivityFlinger;
     49 import com.googlecode.android_scripting.BaseApplication;
     50 import com.googlecode.android_scripting.Constants;
     51 import com.googlecode.android_scripting.FileUtils;
     52 import com.googlecode.android_scripting.IntentBuilders;
     53 import com.googlecode.android_scripting.Log;
     54 import com.googlecode.android_scripting.R;
     55 import com.googlecode.android_scripting.ScriptListAdapter;
     56 import com.googlecode.android_scripting.ScriptStorageAdapter;
     57 import com.googlecode.android_scripting.facade.FacadeConfiguration;
     58 import com.googlecode.android_scripting.interpreter.Interpreter;
     59 import com.googlecode.android_scripting.interpreter.InterpreterConfiguration;
     60 import com.googlecode.android_scripting.interpreter.InterpreterConfiguration.ConfigurationObserver;
     61 import com.googlecode.android_scripting.interpreter.InterpreterConstants;
     62 
     63 import java.io.File;
     64 import java.util.Collections;
     65 import java.util.Comparator;
     66 import java.util.HashMap;
     67 import java.util.LinkedHashMap;
     68 import java.util.List;
     69 import java.util.Map.Entry;
     70 
     71 /**
     72  * Manages creation, deletion, and execution of stored scripts.
     73  *
     74  * @author Damon Kohler (damonkohler (at) gmail.com)
     75  */
     76 public class ScriptManager extends ListActivity {
     77 
     78   private final static String EMPTY = "";
     79 
     80   private List<File> mScripts;
     81   private ScriptManagerAdapter mAdapter;
     82   private SharedPreferences mPreferences;
     83   private HashMap<Integer, Interpreter> mAddMenuIds;
     84   private ScriptListObserver mObserver;
     85   private InterpreterConfiguration mConfiguration;
     86   private SearchManager mManager;
     87   private boolean mInSearchResultMode = false;
     88   private String mQuery = EMPTY;
     89   private File mCurrentDir;
     90   private final File mBaseDir = new File(InterpreterConstants.SCRIPTS_ROOT);
     91   private final Handler mHandler = new Handler();
     92   private File mCurrent;
     93 
     94   private static enum RequestCode {
     95     INSTALL_INTERPETER, QRCODE_ADD
     96   }
     97 
     98   private static enum MenuId {
     99     DELETE, HELP, FOLDER_ADD, QRCODE_ADD, INTERPRETER_MANAGER, PREFERENCES, LOGCAT_VIEWER,
    100     TRIGGER_MANAGER, REFRESH, SEARCH, RENAME, EXTERNAL;
    101     public int getId() {
    102       return ordinal() + Menu.FIRST;
    103     }
    104   }
    105 
    106   @Override
    107   public void onCreate(Bundle savedInstanceState) {
    108     super.onCreate(savedInstanceState);
    109     CustomizeWindow.requestCustomTitle(this, "Scripts", R.layout.script_manager);
    110     if (FileUtils.externalStorageMounted()) {
    111       File sl4a = mBaseDir.getParentFile();
    112       if (!sl4a.exists()) {
    113         sl4a.mkdir();
    114         try {
    115           FileUtils.chmod(sl4a, 0755); // Handle the sl4a parent folder first.
    116         } catch (Exception e) {
    117           // Not much we can do here if it doesn't work.
    118         }
    119       }
    120       if (!FileUtils.makeDirectories(mBaseDir, 0755)) {
    121         new AlertDialog.Builder(this)
    122             .setTitle("Error")
    123             .setMessage(
    124                 "Failed to create scripts directory.\n" + mBaseDir + "\n"
    125                     + "Please check the permissions of your external storage media.")
    126             .setIcon(android.R.drawable.ic_dialog_alert).setPositiveButton("Ok", null).show();
    127       }
    128     } else {
    129       new AlertDialog.Builder(this).setTitle("External Storage Unavilable")
    130           .setMessage("Scripts will be unavailable as long as external storage is unavailable.")
    131           .setIcon(android.R.drawable.ic_dialog_alert).setPositiveButton("Ok", null).show();
    132     }
    133 
    134     mCurrentDir = mBaseDir;
    135     mPreferences = PreferenceManager.getDefaultSharedPreferences(this);
    136     mAdapter = new ScriptManagerAdapter(this);
    137     mObserver = new ScriptListObserver();
    138     mAdapter.registerDataSetObserver(mObserver);
    139     mConfiguration = ((BaseApplication) getApplication()).getInterpreterConfiguration();
    140     mManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE);
    141 
    142     registerForContextMenu(getListView());
    143     updateAndFilterScriptList(mQuery);
    144     setListAdapter(mAdapter);
    145     ActivityFlinger.attachView(getListView(), this);
    146     ActivityFlinger.attachView(getWindow().getDecorView(), this);
    147     startService(IntentBuilders.buildTriggerServiceIntent());
    148     handleIntent(getIntent());
    149   }
    150 
    151   @Override
    152   protected void onNewIntent(Intent intent) {
    153     handleIntent(intent);
    154   }
    155 
    156   @SuppressWarnings("serial")
    157   private void updateAndFilterScriptList(final String query) {
    158     List<File> scripts;
    159     if (mPreferences.getBoolean("show_all_files", false)) {
    160       scripts = ScriptStorageAdapter.listAllScripts(mCurrentDir);
    161     } else {
    162       scripts = ScriptStorageAdapter.listExecutableScripts(mCurrentDir, mConfiguration);
    163     }
    164     mScripts = Lists.newArrayList(Collections2.filter(scripts, new Predicate<File>() {
    165       @Override
    166       public boolean apply(File file) {
    167         return file.getName().toLowerCase().contains(query.toLowerCase());
    168       }
    169     }));
    170 
    171     // TODO(tturney): Add a text view that shows the queried text.
    172     synchronized (mQuery) {
    173       if (!mQuery.equals(query)) {
    174         if (query != null || !query.equals(EMPTY)) {
    175           mQuery = query;
    176         }
    177       }
    178     }
    179 
    180     if ((mScripts.size() == 0) && findViewById(android.R.id.empty) != null) {
    181       ((TextView) findViewById(android.R.id.empty)).setText("No matches found.");
    182     }
    183 
    184     // TODO(damonkohler): Extending the File class here seems odd.
    185     if (!mCurrentDir.equals(mBaseDir)) {
    186       mScripts.add(0, new File(mCurrentDir.getParent()) {
    187         @Override
    188         public boolean isDirectory() {
    189           return true;
    190         }
    191 
    192         @Override
    193         public String getName() {
    194           return "..";
    195         }
    196       });
    197     }
    198   }
    199 
    200   private void handleIntent(Intent intent) {
    201     if (Intent.ACTION_SEARCH.equals(intent.getAction())) {
    202       mInSearchResultMode = true;
    203       String query = intent.getStringExtra(SearchManager.QUERY);
    204       updateAndFilterScriptList(query);
    205       mAdapter.notifyDataSetChanged();
    206     }
    207   }
    208 
    209   @Override
    210   public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
    211     menu.add(Menu.NONE, MenuId.RENAME.getId(), Menu.NONE, "Rename");
    212     menu.add(Menu.NONE, MenuId.DELETE.getId(), Menu.NONE, "Delete");
    213   }
    214 
    215   @Override
    216   public boolean onContextItemSelected(MenuItem item) {
    217     AdapterView.AdapterContextMenuInfo info;
    218     try {
    219       info = (AdapterView.AdapterContextMenuInfo) item.getMenuInfo();
    220     } catch (ClassCastException e) {
    221       Log.e("Bad menuInfo", e);
    222       return false;
    223     }
    224     File file = (File) mAdapter.getItem(info.position);
    225     int itemId = item.getItemId();
    226     if (itemId == MenuId.DELETE.getId()) {
    227       delete(file);
    228       return true;
    229     } else if (itemId == MenuId.RENAME.getId()) {
    230       rename(file);
    231       return true;
    232     }
    233     return false;
    234   }
    235 
    236   @Override
    237   public boolean onKeyDown(int keyCode, KeyEvent event) {
    238     if (keyCode == KeyEvent.KEYCODE_BACK && mInSearchResultMode) {
    239       mInSearchResultMode = false;
    240       mAdapter.notifyDataSetInvalidated();
    241       return true;
    242     }
    243     return super.onKeyDown(keyCode, event);
    244   }
    245 
    246   @Override
    247   public void onStop() {
    248     super.onStop();
    249     mConfiguration.unregisterObserver(mObserver);
    250   }
    251 
    252   @Override
    253   public void onStart() {
    254     super.onStart();
    255     mConfiguration.registerObserver(mObserver);
    256   }
    257 
    258   @Override
    259   protected void onResume() {
    260     super.onResume();
    261     if (!mInSearchResultMode && findViewById(android.R.id.empty) != null) {
    262       ((TextView) findViewById(android.R.id.empty)).setText(R.string.no_scripts_message);
    263     }
    264     updateAndFilterScriptList(mQuery);
    265     mAdapter.notifyDataSetChanged();
    266   }
    267 
    268   @Override
    269   public boolean onPrepareOptionsMenu(Menu menu) {
    270     super.onPrepareOptionsMenu(menu);
    271     menu.clear();
    272     buildMenuIdMaps();
    273     buildAddMenu(menu);
    274     buildSwitchActivityMenu(menu);
    275     menu.add(Menu.NONE, MenuId.SEARCH.getId(), Menu.NONE, "Search").setIcon(
    276         R.drawable.ic_menu_search);
    277     menu.add(Menu.NONE, MenuId.PREFERENCES.getId(), Menu.NONE, "Preferences").setIcon(
    278         android.R.drawable.ic_menu_preferences);
    279     menu.add(Menu.NONE, MenuId.REFRESH.getId(), Menu.NONE, "Refresh").setIcon(
    280         R.drawable.ic_menu_refresh);
    281     return true;
    282   }
    283 
    284   private void buildSwitchActivityMenu(Menu menu) {
    285     Menu subMenu =
    286         menu.addSubMenu(Menu.NONE, Menu.NONE, Menu.NONE, "View").setIcon(
    287             android.R.drawable.ic_menu_more);
    288     subMenu.add(Menu.NONE, MenuId.INTERPRETER_MANAGER.getId(), Menu.NONE, "Interpreters");
    289     subMenu.add(Menu.NONE, MenuId.TRIGGER_MANAGER.getId(), Menu.NONE, "Triggers");
    290     subMenu.add(Menu.NONE, MenuId.LOGCAT_VIEWER.getId(), Menu.NONE, "Logcat");
    291   }
    292 
    293   private void buildMenuIdMaps() {
    294     mAddMenuIds = new LinkedHashMap<Integer, Interpreter>();
    295     int i = MenuId.values().length + Menu.FIRST;
    296     List<Interpreter> installed = mConfiguration.getInstalledInterpreters();
    297     Collections.sort(installed, new Comparator<Interpreter>() {
    298       @Override
    299       public int compare(Interpreter interpreterA, Interpreter interpreterB) {
    300         return interpreterA.getNiceName().compareTo(interpreterB.getNiceName());
    301       }
    302     });
    303     for (Interpreter interpreter : installed) {
    304       mAddMenuIds.put(i, interpreter);
    305       ++i;
    306     }
    307   }
    308 
    309   private void buildAddMenu(Menu menu) {
    310     Menu addMenu =
    311         menu.addSubMenu(Menu.NONE, Menu.NONE, Menu.NONE, "Add").setIcon(
    312             android.R.drawable.ic_menu_add);
    313     addMenu.add(Menu.NONE, MenuId.FOLDER_ADD.getId(), Menu.NONE, "Folder");
    314     for (Entry<Integer, Interpreter> entry : mAddMenuIds.entrySet()) {
    315       addMenu.add(Menu.NONE, entry.getKey(), Menu.NONE, entry.getValue().getNiceName());
    316     }
    317     addMenu.add(Menu.NONE, MenuId.QRCODE_ADD.getId(), Menu.NONE, "Scan Barcode");
    318   }
    319 
    320   @Override
    321   public boolean onOptionsItemSelected(MenuItem item) {
    322     int itemId = item.getItemId();
    323     if (itemId == MenuId.INTERPRETER_MANAGER.getId()) {
    324       // Show interpreter manger.
    325       Intent i = new Intent(this, InterpreterManager.class);
    326       startActivity(i);
    327     } else if (mAddMenuIds.containsKey(itemId)) {
    328       // Add a new script.
    329       Intent intent = new Intent(Constants.ACTION_EDIT_SCRIPT);
    330       Interpreter interpreter = mAddMenuIds.get(itemId);
    331       intent.putExtra(Constants.EXTRA_SCRIPT_PATH,
    332           new File(mCurrentDir.getPath(), interpreter.getExtension()).getPath());
    333       intent.putExtra(Constants.EXTRA_SCRIPT_CONTENT, interpreter.getContentTemplate());
    334       intent.putExtra(Constants.EXTRA_IS_NEW_SCRIPT, true);
    335       startActivity(intent);
    336       synchronized (mQuery) {
    337         mQuery = EMPTY;
    338       }
    339     } else if (itemId == MenuId.QRCODE_ADD.getId()) {
    340       try {
    341         Intent intent = new Intent("com.google.zxing.client.android.SCAN");
    342         startActivityForResult(intent, RequestCode.QRCODE_ADD.ordinal());
    343       }catch(ActivityNotFoundException e) {
    344         Log.e("No handler found to Scan a QR Code!", e);
    345       }
    346     } else if (itemId == MenuId.FOLDER_ADD.getId()) {
    347       addFolder();
    348     } else if (itemId == MenuId.PREFERENCES.getId()) {
    349       startActivity(new Intent(this, Preferences.class));
    350     } else if (itemId == MenuId.TRIGGER_MANAGER.getId()) {
    351       startActivity(new Intent(this, TriggerManager.class));
    352     } else if (itemId == MenuId.LOGCAT_VIEWER.getId()) {
    353       startActivity(new Intent(this, LogcatViewer.class));
    354     } else if (itemId == MenuId.REFRESH.getId()) {
    355       updateAndFilterScriptList(mQuery);
    356       mAdapter.notifyDataSetChanged();
    357     } else if (itemId == MenuId.SEARCH.getId()) {
    358       onSearchRequested();
    359     }
    360     return true;
    361   }
    362 
    363   @Override
    364   protected void onListItemClick(ListView list, View view, int position, long id) {
    365     final File file = (File) list.getItemAtPosition(position);
    366     mCurrent = file;
    367     if (file.isDirectory()) {
    368       mCurrentDir = file;
    369       mAdapter.notifyDataSetInvalidated();
    370       return;
    371     }
    372     doDialogMenu();
    373     return;
    374   }
    375 
    376   // Quickedit chokes on sdk 3 or below, and some Android builds. Provides alternative menu.
    377   private void doDialogMenu() {
    378     AlertDialog.Builder builder = new AlertDialog.Builder(this);
    379     final CharSequence[] menuList =
    380         { "Run Foreground", "Run Background", "Edit", "Delete", "Rename" };
    381     builder.setTitle(mCurrent.getName());
    382     builder.setItems(menuList, new DialogInterface.OnClickListener() {
    383 
    384       @Override
    385       public void onClick(DialogInterface dialog, int which) {
    386         Intent intent;
    387         switch (which) {
    388         case 0:
    389           intent = new Intent(ScriptManager.this, ScriptingLayerService.class);
    390           intent.setAction(Constants.ACTION_LAUNCH_FOREGROUND_SCRIPT);
    391           intent.putExtra(Constants.EXTRA_SCRIPT_PATH, mCurrent.getPath());
    392           startService(intent);
    393           break;
    394         case 1:
    395           intent = new Intent(ScriptManager.this, ScriptingLayerService.class);
    396           intent.setAction(Constants.ACTION_LAUNCH_BACKGROUND_SCRIPT);
    397           intent.putExtra(Constants.EXTRA_SCRIPT_PATH, mCurrent.getPath());
    398           startService(intent);
    399           break;
    400         case 2:
    401           editScript(mCurrent);
    402           break;
    403         case 3:
    404           delete(mCurrent);
    405           break;
    406         case 4:
    407           rename(mCurrent);
    408           break;
    409         }
    410       }
    411     });
    412     builder.show();
    413   }
    414 
    415   /**
    416    * Opens the script for editing.
    417    *
    418    * @param script
    419    *          the name of the script to edit
    420    */
    421   private void editScript(File script) {
    422     Intent i = new Intent(Constants.ACTION_EDIT_SCRIPT);
    423     i.putExtra(Constants.EXTRA_SCRIPT_PATH, script.getAbsolutePath());
    424     startActivity(i);
    425   }
    426 
    427   private void delete(final File file) {
    428     AlertDialog.Builder alert = new AlertDialog.Builder(this);
    429     alert.setTitle("Delete");
    430     alert.setMessage("Would you like to delete " + file.getName() + "?");
    431     alert.setPositiveButton("Yes", new DialogInterface.OnClickListener() {
    432       public void onClick(DialogInterface dialog, int whichButton) {
    433         FileUtils.delete(file);
    434         mScripts.remove(file);
    435         mAdapter.notifyDataSetChanged();
    436       }
    437     });
    438     alert.setNegativeButton("No", new DialogInterface.OnClickListener() {
    439       public void onClick(DialogInterface dialog, int whichButton) {
    440         // Ignore.
    441       }
    442     });
    443     alert.show();
    444   }
    445 
    446   private void addFolder() {
    447     final EditText folderName = new EditText(this);
    448     folderName.setHint("Folder Name");
    449     AlertDialog.Builder alert = new AlertDialog.Builder(this);
    450     alert.setTitle("Add Folder");
    451     alert.setView(folderName);
    452     alert.setPositiveButton("Ok", new DialogInterface.OnClickListener() {
    453       public void onClick(DialogInterface dialog, int whichButton) {
    454         String name = folderName.getText().toString();
    455         if (name.length() == 0) {
    456           Log.e(ScriptManager.this, "Folder name is empty.");
    457           return;
    458         } else {
    459           for (File f : mScripts) {
    460             if (f.getName().equals(name)) {
    461               Log.e(ScriptManager.this, String.format("Folder \"%s\" already exists.", name));
    462               return;
    463             }
    464           }
    465         }
    466         File dir = new File(mCurrentDir, name);
    467         if (!FileUtils.makeDirectories(dir, 0755)) {
    468           Log.e(ScriptManager.this, String.format("Cannot create folder \"%s\".", name));
    469         }
    470         mAdapter.notifyDataSetInvalidated();
    471       }
    472     });
    473     alert.show();
    474   }
    475 
    476   private void rename(final File file) {
    477     final EditText newName = new EditText(this);
    478     newName.setText(file.getName());
    479     AlertDialog.Builder alert = new AlertDialog.Builder(this);
    480     alert.setTitle("Rename");
    481     alert.setView(newName);
    482     alert.setPositiveButton("Ok", new DialogInterface.OnClickListener() {
    483       public void onClick(DialogInterface dialog, int whichButton) {
    484         String name = newName.getText().toString();
    485         if (name.length() == 0) {
    486           Log.e(ScriptManager.this, "Name is empty.");
    487           return;
    488         } else {
    489           for (File f : mScripts) {
    490             if (f.getName().equals(name)) {
    491               Log.e(ScriptManager.this, String.format("\"%s\" already exists.", name));
    492               return;
    493             }
    494           }
    495         }
    496         if (!FileUtils.rename(file, name)) {
    497           throw new RuntimeException(String.format("Cannot rename \"%s\".", file.getPath()));
    498         }
    499         mAdapter.notifyDataSetInvalidated();
    500       }
    501     });
    502     alert.show();
    503   }
    504 
    505   @Override
    506   protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    507     RequestCode request = RequestCode.values()[requestCode];
    508     if (resultCode == RESULT_OK) {
    509       switch (request) {
    510       case QRCODE_ADD:
    511         writeScriptFromBarcode(data);
    512         break;
    513       default:
    514         break;
    515       }
    516     } else {
    517       switch (request) {
    518       case QRCODE_ADD:
    519         break;
    520       default:
    521         break;
    522       }
    523     }
    524     mAdapter.notifyDataSetInvalidated();
    525   }
    526 
    527   private void writeScriptFromBarcode(Intent data) {
    528     String result = data.getStringExtra("SCAN_RESULT");
    529     if (result == null) {
    530       Log.e(this, "Invalid QR code content.");
    531       return;
    532     }
    533     String contents[] = result.split("\n", 2);
    534     if (contents.length != 2) {
    535       Log.e(this, "Invalid QR code content.");
    536       return;
    537     }
    538     String title = contents[0];
    539     String body = contents[1];
    540     File script = new File(mCurrentDir, title);
    541     ScriptStorageAdapter.writeScript(script, body);
    542   }
    543 
    544   @Override
    545   public void onDestroy() {
    546     super.onDestroy();
    547     mConfiguration.unregisterObserver(mObserver);
    548     mManager.setOnCancelListener(null);
    549   }
    550 
    551   private class ScriptListObserver extends DataSetObserver implements ConfigurationObserver {
    552     @Override
    553     public void onInvalidated() {
    554       updateAndFilterScriptList(EMPTY);
    555     }
    556 
    557     @Override
    558     public void onConfigurationChanged() {
    559       runOnUiThread(new Runnable() {
    560         @Override
    561         public void run() {
    562           updateAndFilterScriptList(mQuery);
    563           mAdapter.notifyDataSetChanged();
    564         }
    565       });
    566     }
    567   }
    568 
    569   private class ScriptManagerAdapter extends ScriptListAdapter {
    570     public ScriptManagerAdapter(Context context) {
    571       super(context);
    572     }
    573 
    574     @Override
    575     protected List<File> getScriptList() {
    576       return mScripts;
    577     }
    578   }
    579 }
    580