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