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.Activity; 20 import android.app.AlertDialog; 21 import android.app.Dialog; 22 import android.content.DialogInterface; 23 import android.content.DialogInterface.OnClickListener; 24 import android.content.Intent; 25 import android.content.SharedPreferences; 26 import android.content.SharedPreferences.Editor; 27 import android.media.AudioManager; 28 import android.os.Bundle; 29 import android.preference.PreferenceManager; 30 import android.text.Editable; 31 import android.text.InputFilter; 32 import android.text.InputType; 33 import android.text.Selection; 34 import android.text.Spanned; 35 import android.text.TextWatcher; 36 import android.text.style.UnderlineSpan; 37 import android.view.KeyEvent; 38 import android.view.Menu; 39 import android.view.MenuItem; 40 import android.view.View; 41 import android.widget.CheckBox; 42 import android.widget.EditText; 43 import android.widget.Toast; 44 45 import com.googlecode.android_scripting.BaseApplication; 46 import com.googlecode.android_scripting.Constants; 47 import com.googlecode.android_scripting.FileUtils; 48 import com.googlecode.android_scripting.Log; 49 import com.googlecode.android_scripting.R; 50 import com.googlecode.android_scripting.ScriptStorageAdapter; 51 import com.googlecode.android_scripting.interpreter.Interpreter; 52 import com.googlecode.android_scripting.interpreter.InterpreterConfiguration; 53 import com.googlecode.android_scripting.service.ScriptingLayerService; 54 55 import java.io.File; 56 import java.io.IOException; 57 import java.util.ArrayList; 58 import java.util.Arrays; 59 import java.util.List; 60 import java.util.Vector; 61 import java.util.regex.Matcher; 62 import java.util.regex.Pattern; 63 64 /** 65 * A text editor for scripts. 66 * 67 */ 68 public class ScriptEditor extends Activity implements OnClickListener { 69 private static final int DIALOG_FIND_REPLACE = 2; 70 private static final int DIALOG_LINE = 1; 71 private EditText mNameText; 72 private EditText mContentText; 73 private boolean mScheduleMoveLeft; 74 private String mLastSavedContent; 75 private SharedPreferences mPreferences; 76 private InterpreterConfiguration mConfiguration; 77 private ContentTextWatcher mWatcher; 78 private EditHistory mHistory; 79 private File mScript; 80 private EditText mLineNo; 81 82 private boolean mIsUndoOrRedo = false; 83 private boolean mEnableAutoClose; 84 private boolean mAutoIndent; 85 86 private EditText mSearchFind; 87 private EditText mSearchReplace; 88 private CheckBox mSearchCase; 89 private CheckBox mSearchWord; 90 private CheckBox mSearchAll; 91 private CheckBox mSearchStart; 92 93 private static enum MenuId { 94 SAVE, SAVE_AND_RUN, PREFERENCES, API_BROWSER, HELP, SHARE, GOTO, SEARCH; getId()95 public int getId() { 96 return ordinal() + Menu.FIRST; 97 } 98 } 99 100 private static enum RequestCode { 101 RPC_HELP 102 } 103 readIntPref(String key, int defaultValue, int maxValue)104 private int readIntPref(String key, int defaultValue, int maxValue) { 105 int val; 106 try { 107 val = Integer.parseInt(mPreferences.getString(key, Integer.toString(defaultValue))); 108 } catch (NumberFormatException e) { 109 val = defaultValue; 110 } 111 val = Math.max(0, Math.min(val, maxValue)); 112 return val; 113 } 114 115 @Override onCreate(Bundle savedInstanceState)116 protected void onCreate(Bundle savedInstanceState) { 117 super.onCreate(savedInstanceState); 118 setContentView(R.layout.script_editor); 119 mNameText = (EditText) findViewById(R.id.script_editor_title); 120 mContentText = (EditText) findViewById(R.id.script_editor_body); 121 mHistory = new EditHistory(); 122 mWatcher = new ContentTextWatcher(mHistory); 123 mPreferences = PreferenceManager.getDefaultSharedPreferences(this); 124 updatePreferences(); 125 126 mScript = new File(getIntent().getStringExtra(Constants.EXTRA_SCRIPT_PATH)); 127 mNameText.setText(mScript.getName()); 128 mNameText.setSelected(true); 129 // NOTE: This appears to be the only way to get Android to put the cursor to the beginning of 130 // the EditText field. 131 mNameText.setSelection(1); 132 mNameText.extendSelection(0); 133 mNameText.setSelection(0); 134 mLastSavedContent = getIntent().getStringExtra(Constants.EXTRA_SCRIPT_CONTENT); 135 if (mLastSavedContent == null) { 136 try { 137 mLastSavedContent = FileUtils.readToString(mScript); 138 } catch (IOException e) { 139 Log.e("Failed to read script.", e); 140 mLastSavedContent = ""; 141 } finally { 142 } 143 } 144 145 mContentText.setText(mLastSavedContent); 146 InputFilter[] oldFilters = mContentText.getFilters(); 147 List<InputFilter> filters = new ArrayList<InputFilter>(oldFilters.length + 1); 148 filters.addAll(Arrays.asList(oldFilters)); 149 filters.add(new ContentInputFilter()); 150 mContentText.setFilters(filters.toArray(oldFilters)); 151 mContentText.addTextChangedListener(mWatcher); 152 mConfiguration = ((BaseApplication) getApplication()).getInterpreterConfiguration(); 153 // Disables volume key beep. 154 setVolumeControlStream(AudioManager.STREAM_MUSIC); 155 mLineNo = new EditText(this); 156 mLineNo.setInputType(InputType.TYPE_CLASS_NUMBER); 157 int lastLocation = mPreferences.getInt("lasteditpos." + mScript, -1); 158 if (lastLocation >= 0) { 159 mContentText.requestFocus(); 160 mContentText.setSelection(lastLocation); 161 } 162 } 163 164 @Override onResume()165 protected void onResume() { 166 super.onResume(); 167 updatePreferences(); 168 } 169 updatePreferences()170 private void updatePreferences() { 171 mContentText.setTextSize(readIntPref("editor_fontsize", 10, 30)); 172 mEnableAutoClose = mPreferences.getBoolean("enableAutoClose", true); 173 mAutoIndent = mPreferences.getBoolean("editor_auto_indent", false); 174 mContentText.setHorizontallyScrolling(mPreferences.getBoolean("editor_no_wrap", false)); 175 } 176 177 @Override onCreateOptionsMenu(Menu menu)178 public boolean onCreateOptionsMenu(Menu menu) { 179 super.onCreateOptionsMenu(menu); 180 menu.add(0, MenuId.SAVE.getId(), 0, "Save & Exit").setIcon(android.R.drawable.ic_menu_save); 181 menu.add(0, MenuId.SAVE_AND_RUN.getId(), 0, "Save & Run").setIcon( 182 android.R.drawable.ic_media_play); 183 menu.add(0, MenuId.PREFERENCES.getId(), 0, "Preferences").setIcon( 184 android.R.drawable.ic_menu_preferences); 185 menu.add(0, MenuId.API_BROWSER.getId(), 0, "API Browser").setIcon( 186 android.R.drawable.ic_menu_info_details); 187 menu.add(0, MenuId.SHARE.getId(), 0, "Share").setIcon(android.R.drawable.ic_menu_share); 188 menu.add(0, MenuId.GOTO.getId(), 0, "GoTo").setIcon(android.R.drawable.ic_menu_directions); 189 menu.add(0, MenuId.SEARCH.getId(), 0, "Find").setIcon(android.R.drawable.ic_menu_search); 190 return true; 191 } 192 193 @Override onOptionsItemSelected(MenuItem item)194 public boolean onOptionsItemSelected(MenuItem item) { 195 if (item.getItemId() == MenuId.SAVE.getId()) { 196 save(); 197 finish(); 198 } else if (item.getItemId() == MenuId.SAVE_AND_RUN.getId()) { 199 save(); 200 Interpreter interpreter = 201 mConfiguration.getInterpreterForScript(mNameText.getText().toString()); 202 if (interpreter != null) { // We may be editing an unknown type. 203 Intent intent = new Intent(this, ScriptingLayerService.class); 204 intent.setAction(Constants.ACTION_LAUNCH_FOREGROUND_SCRIPT); 205 intent.putExtra(Constants.EXTRA_SCRIPT_PATH, mScript.getAbsolutePath()); 206 startService(intent); 207 } else { 208 // TODO(damonkohler): Should remove menu option. 209 Toast.makeText(this, "Can't run this type.", Toast.LENGTH_SHORT).show(); 210 } 211 finish(); 212 } else if (item.getItemId() == MenuId.PREFERENCES.getId()) { 213 startActivity(new Intent(this, Preferences.class)); 214 } else if (item.getItemId() == MenuId.API_BROWSER.getId()) { 215 Intent intent = new Intent(this, ApiBrowser.class); 216 intent.putExtra(Constants.EXTRA_SCRIPT_PATH, mNameText.getText().toString()); 217 intent.putExtra(Constants.EXTRA_INTERPRETER_NAME, 218 mConfiguration.getInterpreterForScript(mNameText.getText().toString()).getName()); 219 intent.putExtra(Constants.EXTRA_SCRIPT_TEXT, mContentText.getText().toString()); 220 startActivityForResult(intent, RequestCode.RPC_HELP.ordinal()); 221 } else if (item.getItemId() == MenuId.SHARE.getId()) { 222 Intent intent = new Intent(Intent.ACTION_SEND); 223 intent.putExtra(Intent.EXTRA_TEXT, mContentText.getText().toString()); 224 intent.putExtra(Intent.EXTRA_SUBJECT, "Share " + mNameText.getText().toString()); 225 intent.setType("text/plain"); 226 startActivity(Intent.createChooser(intent, "Send Script to:")); 227 } else if (item.getItemId() == MenuId.GOTO.getId()) { 228 showDialog(DIALOG_LINE); 229 } else if (item.getItemId() == MenuId.SEARCH.getId()) { 230 showDialog(DIALOG_FIND_REPLACE); 231 } 232 return super.onOptionsItemSelected(item); 233 } 234 235 @Override onActivityResult(int requestCode, int resultCode, Intent data)236 protected void onActivityResult(int requestCode, int resultCode, Intent data) { 237 super.onActivityResult(requestCode, resultCode, data); 238 RequestCode request = RequestCode.values()[requestCode]; 239 240 if (resultCode == RESULT_OK) { 241 switch (request) { 242 case RPC_HELP: 243 String rpcText = data.getStringExtra(Constants.EXTRA_RPC_HELP_TEXT); 244 insertContent(rpcText); 245 break; 246 default: 247 break; 248 } 249 } else { 250 switch (request) { 251 case RPC_HELP: 252 break; 253 default: 254 break; 255 } 256 } 257 } 258 save()259 private void save() { 260 int start = mContentText.getSelectionStart(); 261 mLastSavedContent = mContentText.getText().toString(); 262 mScript = new File(mScript.getParent(), mNameText.getText().toString()); 263 ScriptStorageAdapter.writeScript(mScript, mLastSavedContent); 264 Toast.makeText(this, "Saved " + mNameText.getText().toString(), Toast.LENGTH_SHORT).show(); 265 Editor e = mPreferences.edit(); 266 e.putInt("lasteditpos." + mScript, start); 267 e.commit(); 268 } 269 insertContent(String text)270 private void insertContent(String text) { 271 int selectionStart = Math.min(mContentText.getSelectionStart(), mContentText.getSelectionEnd()); 272 int selectionEnd = Math.max(mContentText.getSelectionStart(), mContentText.getSelectionEnd()); 273 mContentText.getEditableText().replace(selectionStart, selectionEnd, text); 274 } 275 276 @Override onKeyDown(int keyCode, KeyEvent event)277 public boolean onKeyDown(int keyCode, KeyEvent event) { 278 if (keyCode == KeyEvent.KEYCODE_BACK && hasContentChanged()) { 279 AlertDialog.Builder alert = new AlertDialog.Builder(this); 280 setVolumeControlStream(AudioManager.STREAM_MUSIC); 281 alert.setCancelable(false); 282 alert.setTitle("Confirm exit"); 283 alert.setMessage("Would you like to save?"); 284 alert.setPositiveButton("Yes", new DialogInterface.OnClickListener() { 285 public void onClick(DialogInterface dialog, int whichButton) { 286 save(); 287 finish(); 288 } 289 }); 290 alert.setNegativeButton("No", new DialogInterface.OnClickListener() { 291 public void onClick(DialogInterface dialog, int whichButton) { 292 finish(); 293 } 294 }); 295 alert.setNeutralButton("Cancel", new DialogInterface.OnClickListener() { 296 public void onClick(DialogInterface dialog, int whichButton) { 297 } 298 }); 299 alert.show(); 300 return true; 301 } else if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) { 302 redo(); 303 return true; 304 } else if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) { 305 undo(); 306 return true; 307 } else if (keyCode == KeyEvent.KEYCODE_SEARCH) { 308 showDialog(DIALOG_FIND_REPLACE); 309 return true; 310 } else { 311 return super.onKeyDown(keyCode, event); 312 } 313 } 314 315 @Override onCreateDialog(int id, Bundle args)316 protected Dialog onCreateDialog(int id, Bundle args) { 317 AlertDialog.Builder b = new AlertDialog.Builder(this); 318 if (id == DIALOG_LINE) { 319 b.setTitle("Goto Line"); 320 b.setView(mLineNo); 321 b.setPositiveButton("Ok", new OnClickListener() { 322 323 @Override 324 public void onClick(DialogInterface dialog, int which) { 325 gotoLine(Integer.parseInt(mLineNo.getText().toString())); 326 } 327 }); 328 b.setNegativeButton("Cancel", null); 329 return b.create(); 330 } else if (id == DIALOG_FIND_REPLACE) { 331 View v = getLayoutInflater().inflate(R.layout.findreplace, null); 332 mSearchFind = (EditText) v.findViewById(R.id.searchFind); 333 mSearchReplace = (EditText) v.findViewById(R.id.searchReplace); 334 mSearchAll = (CheckBox) v.findViewById(R.id.searchAll); 335 mSearchCase = (CheckBox) v.findViewById(R.id.searchCase); 336 mSearchStart = (CheckBox) v.findViewById(R.id.searchStart); 337 mSearchWord = (CheckBox) v.findViewById(R.id.searchWord); 338 b.setTitle("Search and Replace"); 339 b.setView(v); 340 b.setPositiveButton("Find", this); 341 b.setNeutralButton("Next", this); 342 b.setNegativeButton("Replace", this); 343 return b.create(); 344 } 345 346 return super.onCreateDialog(id, args); 347 } 348 349 @Override onPrepareDialog(int id, Dialog dialog, Bundle args)350 protected void onPrepareDialog(int id, Dialog dialog, Bundle args) { 351 if (id == DIALOG_LINE) { 352 mLineNo.setText(String.valueOf(getLineNo())); 353 } else if (id == DIALOG_FIND_REPLACE) { 354 mSearchStart.setChecked(false); 355 } 356 super.onPrepareDialog(id, dialog, args); 357 } 358 getLineNo()359 protected int getLineNo() { 360 int pos = mContentText.getSelectionStart(); 361 String text = mContentText.getText().toString(); 362 int i = 0; 363 int n = 1; 364 while (i < pos) { 365 int j = text.indexOf("\n", i); 366 if (j < 0) { 367 break; 368 } 369 i = j + 1; 370 if (i < pos) { 371 n += 1; 372 } 373 } 374 return n; 375 } 376 gotoLine(int line)377 protected void gotoLine(int line) { 378 String text = mContentText.getText().toString(); 379 if (text.length() < 1) { 380 return; 381 } 382 int i = 0; 383 int n = 1; 384 while (i < text.length() && n < line) { 385 int j = text.indexOf("\n", i); 386 if (j < 0) { 387 break; 388 } 389 i = j + 1; 390 n += 1; 391 } 392 mContentText.setSelection(Math.min(text.length() - 1, i)); 393 } 394 395 @Override onUserLeaveHint()396 protected void onUserLeaveHint() { 397 if (hasContentChanged()) { 398 save(); 399 } 400 } 401 hasContentChanged()402 private boolean hasContentChanged() { 403 return !mLastSavedContent.equals(mContentText.getText().toString()); 404 } 405 406 private final class ContentInputFilter implements InputFilter { 407 @Override filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend)408 public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, 409 int dend) { 410 if (end - start == 1) { 411 Interpreter ip = mConfiguration.getInterpreterForScript(mNameText.getText().toString()); 412 String auto = null; 413 if (ip != null && mEnableAutoClose) { 414 auto = ip.getLanguage().autoClose(source.charAt(start)); 415 } 416 // Auto indent code? 417 if (auto == null && source.charAt(start) == '\n' && mAutoIndent) { 418 int i = dstart - 1; 419 int spaces = 0; 420 while ((i >= 0) && dest.charAt(i) != '\n') { 421 i -= 1; // Find start of line. 422 } 423 i += 1; 424 while (i < dest.length() && dest.charAt(i++) == ' ') { 425 spaces += 1; 426 } 427 if (spaces > 0) { 428 return String.format("\n%" + spaces + "s", " "); 429 } 430 } 431 if (auto != null) { 432 mScheduleMoveLeft = true; 433 return auto; 434 } 435 } 436 return null; 437 } 438 } 439 440 private final class ContentTextWatcher implements TextWatcher { 441 private final EditHistory mmEditHistory; 442 private CharSequence mmBeforeChange; 443 private CharSequence mmAfterChange; 444 ContentTextWatcher(EditHistory history)445 private ContentTextWatcher(EditHistory history) { 446 mmEditHistory = history; 447 } 448 449 @Override onTextChanged(CharSequence s, int start, int before, int count)450 public void onTextChanged(CharSequence s, int start, int before, int count) { 451 if (!mIsUndoOrRedo) { 452 mmAfterChange = s.subSequence(start, start + count); 453 mmEditHistory.add(new EditItem(start, mmBeforeChange, mmAfterChange)); 454 } 455 } 456 457 @Override beforeTextChanged(CharSequence s, int start, int count, int after)458 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 459 if (!mIsUndoOrRedo) { 460 mmBeforeChange = s.subSequence(start, start + count); 461 } 462 } 463 464 @Override afterTextChanged(Editable s)465 public void afterTextChanged(Editable s) { 466 if (mScheduleMoveLeft) { 467 mScheduleMoveLeft = false; 468 Selection.moveLeft(mContentText.getText(), mContentText.getLayout()); 469 } 470 } 471 } 472 473 /** 474 * Keeps track of all the edit history of a text. 475 */ 476 private final class EditHistory { 477 int mmPosition = 0; 478 private final Vector<EditItem> mmHistory = new Vector<EditItem>(); 479 480 /** 481 * Adds a new edit operation to the history at the current position. If executed after a call to 482 * getPrevious() removes all the future history (elements with positions >= current history 483 * position). 484 * 485 */ add(EditItem item)486 private void add(EditItem item) { 487 mmHistory.setSize(mmPosition); 488 mmHistory.add(item); 489 mmPosition++; 490 } 491 492 /** 493 * Traverses the history backward by one position, returns and item at that position. 494 */ getPrevious()495 private EditItem getPrevious() { 496 if (mmPosition == 0) { 497 return null; 498 } 499 mmPosition--; 500 return mmHistory.get(mmPosition); 501 } 502 503 /** 504 * Traverses the history forward by one position, returns and item at that position. 505 */ getNext()506 private EditItem getNext() { 507 if (mmPosition == mmHistory.size()) { 508 return null; 509 } 510 EditItem item = mmHistory.get(mmPosition); 511 mmPosition++; 512 return item; 513 } 514 } 515 516 /** 517 * Represents a single edit operation. 518 */ 519 private final class EditItem { 520 private final int mmIndex; 521 private final CharSequence mmBefore; 522 private final CharSequence mmAfter; 523 524 /** 525 * Constructs EditItem of a modification that was applied at position start and replaced 526 * CharSequence before with CharSequence after. 527 */ EditItem(int start, CharSequence before, CharSequence after)528 public EditItem(int start, CharSequence before, CharSequence after) { 529 mmIndex = start; 530 mmBefore = before; 531 mmAfter = after; 532 } 533 } 534 undo()535 private void undo() { 536 EditItem edit = mHistory.getPrevious(); 537 if (edit == null) { 538 return; 539 } 540 Editable text = mContentText.getText(); 541 int start = edit.mmIndex; 542 int end = start + (edit.mmAfter != null ? edit.mmAfter.length() : 0); 543 mIsUndoOrRedo = true; 544 text.replace(start, end, edit.mmBefore); 545 mIsUndoOrRedo = false; 546 // This will get rid of underlines inserted when editor tries to come up with a suggestion. 547 for (Object o : text.getSpans(0, text.length(), UnderlineSpan.class)) { 548 text.removeSpan(o); 549 } 550 Selection.setSelection(text, edit.mmBefore == null ? start : (start + edit.mmBefore.length())); 551 } 552 redo()553 private void redo() { 554 EditItem edit = mHistory.getNext(); 555 if (edit == null) { 556 return; 557 } 558 Editable text = mContentText.getText(); 559 int start = edit.mmIndex; 560 int end = start + (edit.mmBefore != null ? edit.mmBefore.length() : 0); 561 mIsUndoOrRedo = true; 562 text.replace(start, end, edit.mmAfter); 563 mIsUndoOrRedo = false; 564 for (Object o : text.getSpans(0, text.length(), UnderlineSpan.class)) { 565 text.removeSpan(o); 566 } 567 Selection.setSelection(text, edit.mmAfter == null ? start : (start + edit.mmAfter.length())); 568 } 569 570 @Override onClick(DialogInterface dialog, int which)571 public void onClick(DialogInterface dialog, int which) { 572 int start = mContentText.getSelectionStart(); 573 int end = mContentText.getSelectionEnd(); 574 String original = mContentText.getText().toString(); 575 if (start == end || which != AlertDialog.BUTTON_NEGATIVE) { 576 end = original.length(); 577 } 578 if (which == AlertDialog.BUTTON_NEUTRAL) { 579 start += 1; 580 } 581 if (mSearchStart.isChecked()) { 582 start = 0; 583 end = original.length(); 584 } 585 String findText = mSearchFind.getText().toString(); 586 String replaceText = mSearchReplace.getText().toString(); 587 String search = Pattern.quote(findText); 588 int flags = 0; 589 if (!mSearchCase.isChecked()) { 590 flags |= Pattern.CASE_INSENSITIVE; 591 } 592 if (mSearchWord.isChecked()) { 593 search = "\\b" + search + "\\b"; 594 } 595 Pattern p = Pattern.compile(search, flags); 596 Matcher m = p.matcher(original); 597 m.region(start, end); 598 if (!m.find()) { 599 Toast.makeText(this, "Search not found.", Toast.LENGTH_SHORT).show(); 600 return; 601 } 602 int foundpos = m.start(); 603 if (which != AlertDialog.BUTTON_NEGATIVE) { // Find 604 mContentText.setSelection(foundpos, foundpos + findText.length()); 605 } else { // Replace 606 String s; 607 // Seems to be a bug in the android 2.2 implementation of replace... regions not returning 608 // whole string. 609 m = p.matcher(original.substring(start, end)); 610 String replace = Matcher.quoteReplacement(replaceText); 611 if (mSearchAll.isChecked()) { 612 s = m.replaceAll(replace); 613 } else { 614 s = m.replaceFirst(replace); 615 } 616 mContentText.setText(original.substring(0, start) + s + original.substring(end)); 617 mContentText.setSelection(foundpos, foundpos + replaceText.length()); 618 } 619 mContentText.requestFocus(); 620 } 621 } 622