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