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