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