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