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