From 35b98f07bb77f6d90022f19008939468f46306c8 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Sun, 2 Aug 2020 09:43:38 -0400 Subject: [PATCH 01/33] Update library publishing tools --- build.gradle | 2 +- gradle.properties | 4 +- gradle/wrapper/gradle-wrapper.properties | 2 +- library/build.gradle | 150 ++++++++++++----------- 4 files changed, 80 insertions(+), 78 deletions(-) diff --git a/build.gradle b/build.gradle index 9b0fca26..3b8ffcff 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.4.2' + classpath "com.android.tools.build:gradle:4.0.1" } } diff --git a/gradle.properties b/gradle.properties index 7099441e..8c65bbec 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,8 +12,8 @@ # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -VERSION_NAME=4.0.0 -VERSION_CODE=24 +VERSION_NAME=4.0.0-beta01 +VERSION_CODE=25 GROUP=com.splitwise ANDROID_BUILD_MIN_SDK_VERSION=14 ANDROID_BUILD_TARGET_SDK_VERSION=28 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 92b456dc..264801a5 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip diff --git a/library/build.gradle b/library/build.gradle index 52c2a1e3..94c9b7eb 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -1,11 +1,14 @@ -apply plugin: 'com.android.library' - +plugins { + id("com.android.library") + id("maven-publish") + id("signing") +} android { compileSdkVersion Integer.parseInt(project.ANDROID_BUILD_SDK_VERSION) configurations { - javadocSources.extendsFrom implementation + javadocSources.extendsFrom(implementation) } defaultConfig { @@ -14,6 +17,7 @@ android { versionCode Integer.parseInt(project.VERSION_CODE) versionName project.VERSION_NAME } + buildTypes { release { minifyEnabled false @@ -31,90 +35,88 @@ android { } dependencies { - testImplementation 'junit:junit:4.13' + testImplementation("junit:junit:4.13") - implementation "androidx.annotation:annotation:$ANDROIDX_VERSION" - implementation "androidx.appcompat:appcompat:$ANDROIDX_VERSION" + implementation("androidx.annotation:annotation:$ANDROIDX_VERSION") + implementation("androidx.appcompat:appcompat:$ANDROIDX_VERSION") } -//Release tasks -afterEvaluate { - apply plugin: 'maven' - apply plugin: 'signing' - uploadArchives.repositories.mavenDeployer { - beforeDeployment { - MavenDeployment deployment -> signing.signPom(deployment) - } - - pom.groupId = GROUP - pom.artifactId = POM_ARTIFACT_ID - pom.version = VERSION_NAME - - repository(url: "https://oss.sonatype.org/service/local/staging/deploy/maven2/") { - authentication(userName: hasProperty('NEXUS_USERNAME') ? NEXUS_USERNAME : "", - password: hasProperty('NEXUS_PASSWORD') ? NEXUS_PASSWORD : "") - } +task libraryJavadocs(type: Javadoc) { + failOnError = false + source = android.sourceSets.main.java.srcDirs + classpath += project.files(android.getBootClasspath().join(File.pathSeparator)) + classpath += configurations.javadocSources +} - pom.project { - name POM_NAME - packaging POM_PACKAGING - description POM_DESCRIPTION - url POM_URL +task libraryJavadocsJar(type: Jar, dependsOn: libraryJavadocs) { + archiveClassifier.set("javadoc") + from libraryJavadocs.destinationDir +} - developers { - developer { - id POM_DEVELOPER_ID - name POM_DEVELOPER_NAME - } - } +task librarySourcesJar(type: Jar) { + archiveClassifier.set("sources") + from android.sourceSets.main.java.srcDirs +} - licenses { - license { - name POM_LICENCE_NAME - url POM_LICENCE_URL - distribution POM_LICENCE_DIST - } - } +task jar(type: Jar) { + dependsOn 'assembleRelease' + baseName project.POM_ARTIFACT_ID + version project.VERSION_NAME + from fileTree(dir: 'build/intermediates/classes/release/') +} - scm { - url POM_SCM_URL - connection = POM_SCM_CONNECTION - developerConnection = POM_SCM_DEV_CONNECTION +publishing { + repositories { + maven { + url = "https://oss.sonatype.org/service/local/staging/deploy/maven2/" + credentials { + username = project.properties['NEXUS_USERNAME'] ?: "" + password = project.properties['NEXUS_PASSWORD'] ?: "" } } } - signing { - required { gradle.taskGraph.hasTask("uploadArchives") } - sign configurations.archives - } - - task libraryJavadocs(type: Javadoc) { - failOnError = false - source = android.sourceSets.main.java.srcDirs - classpath += project.files(android.getBootClasspath().join(File.pathSeparator)) - classpath += configurations.javadocSources - } - - task libraryJavadocsJar(type: Jar, dependsOn: libraryJavadocs) { - classifier = 'javadoc' - from libraryJavadocs.destinationDir - } + publications { + maven(MavenPublication) { publication -> + groupId = GROUP + artifactId = POM_ARTIFACT_ID + version = VERSION_NAME + + artifact(librarySourcesJar) + artifact(libraryJavadocsJar) + artifact("$buildDir/outputs/aar/library-release.aar") + + pom { + name = POM_NAME + packaging = POM_PACKAGING + description = POM_DESCRIPTION + url = POM_URL + + licenses { + license { + name = POM_LICENCE_NAME + url = POM_LICENCE_URL + distribution = POM_LICENCE_DIST + } + } - task librarySourcesJar(type: Jar) { - classifier = 'sources' - from android.sourceSets.main.java.srcDirs - } + developers { + developer { + id = POM_DEVELOPER_ID + name = POM_DEVELOPER_NAME + } + } - task jar(type: Jar) { - dependsOn 'assembleRelease' - baseName project.POM_ARTIFACT_ID - version project.VERSION_NAME - from fileTree(dir: 'build/intermediates/classes/release/') + scm { + connection = POM_SCM_CONNECTION + developerConnection = POM_SCM_DEV_CONNECTION + url = POM_SCM_URL + } + } + } } +} - artifacts { - archives libraryJavadocsJar - archives librarySourcesJar - } +signing { + sign publishing.publications.maven } From eb91f4e84d1872eb46e1f12d5d97a9729e052646 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Sun, 2 Aug 2020 09:53:30 -0400 Subject: [PATCH 02/33] Add Google Actions support for simple unit tests --- .github/workflows/android.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/android.yml diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 00000000..500866e5 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,23 @@ +name: Android CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + - name: Build with Gradle + run: ./gradlew build + - name: Run unit tests + run: ./gradlew test From 1143cd2d038f9474e32b56064f5c260c324a94f3 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Sun, 2 Aug 2020 19:15:13 -0400 Subject: [PATCH 03/33] Add instrumentation tests --- .github/workflows/android.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 500866e5..3ba3cef6 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -21,3 +21,16 @@ jobs: run: ./gradlew build - name: Run unit tests run: ./gradlew test + - name: Build integration test target + run: ./gradlew assembleAndroidTest + - name: Configure Google Cloud credentials + env: + GOOGLE_CLOUD_SERVICE_KEY: ${{secrets.GOOGLE_CLOUD_SERVICE_KEY}} + GOOGLE_PROJECT_ID: ${{secrets.GOOGLE_PROJECT_ID}} + run: | + echo $GOOGLE_CLOUD_SERVICE_KEY > ${HOME}/gcloud-service-key.json + gcloud auth activate-service-account --key-file=${HOME}/gcloud-service-key.json + gcloud --quiet config set project $GOOGLE_PROJECT_ID + - name: Run Instrumented Tests with Firebase Test Lab + run: gcloud firebase test android run --type instrumentation --app example/build/outputs/apk/debug/example-debug.apk --test example/build/outputs/apk/androidTest/debug/example-debug-androidTest.apk --device model=Nexus6,version=23,locale=en,orientation=portrait --timeout 30m + From 78f27da60697b8c962de7f8ec63b4657f4ff455c Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Sun, 2 Aug 2020 19:44:51 -0400 Subject: [PATCH 04/33] Clear completion check only after use --- .../java/com/tokenautocomplete/TokenCompleteTextView.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.java b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.java index c5cad5f9..6cb12d94 100644 --- a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.java +++ b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.java @@ -1614,15 +1614,10 @@ public boolean setComposingText(CharSequence text, int newCursorPosition) { text.length() == lastCompletionText.length() + 1 && text.toString().startsWith(lastCompletionText)) { text = text.subSequence(text.length() - 1, text.length()); + lastCompletionText = null; } return super.setComposingText(text, newCursorPosition); } } - - @Override - protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) { - super.onTextChanged(text, start, lengthBefore, lengthAfter); - lastCompletionText = null; - } } From bb51c96b39d90d43e74b2b8cf709ec58dd633c45 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 8 Oct 2020 03:06:33 +0200 Subject: [PATCH 05/33] Don't hook into invalidate() to trigger redrawing spans invalidate() often triggers additional invalidate() calls and it's easy to get into an endless loop or do too much work. Doing additional work in invalidate() is also the cause of a crash when cutting all text. Instead of using an implementation detail of TextView to trigger redrawing the spans we now add or remove an invisible dummy span to achieve the same result. This is very unlikely to break in the future. --- .../java/com/tokenautocomplete/DummySpan.java | 23 ++++++++++ .../TokenCompleteTextView.java | 42 +++++++++---------- 2 files changed, 44 insertions(+), 21 deletions(-) create mode 100644 library/src/main/java/com/tokenautocomplete/DummySpan.java diff --git a/library/src/main/java/com/tokenautocomplete/DummySpan.java b/library/src/main/java/com/tokenautocomplete/DummySpan.java new file mode 100644 index 00000000..b4e75a6b --- /dev/null +++ b/library/src/main/java/com/tokenautocomplete/DummySpan.java @@ -0,0 +1,23 @@ +package com.tokenautocomplete; + +import android.text.TextPaint; +import android.text.style.MetricAffectingSpan; + +import androidx.annotation.NonNull; + +/** + * Invisible MetricAffectingSpan that will trigger a redraw when it is being added to or removed from an Editable. + * + * @see TokenCompleteTextView#redrawTokens() + */ +class DummySpan extends MetricAffectingSpan { + static final DummySpan INSTANCE = new DummySpan(); + + private DummySpan() {} + + @Override + public void updateMeasureState(@NonNull TextPaint textPaint) {} + + @Override + public void updateDrawState(TextPaint tp) {} +} diff --git a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.java b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.java index 6cb12d94..d4863678 100644 --- a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.java +++ b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.java @@ -1,11 +1,9 @@ package com.tokenautocomplete; -import android.annotation.TargetApi; import android.content.Context; import android.content.res.ColorStateList; import android.graphics.Rect; import android.graphics.Typeface; -import android.os.Build; import android.os.Parcel; import android.os.Parcelable; import androidx.annotation.NonNull; @@ -553,25 +551,20 @@ public int getMaxViewSpanWidth() { return (int)maxTextWidth(); } - boolean inInvalidate = false; + public void redrawTokens() { + // There's no straight-forward way to convince the widget to redraw the text and spans. We trigger a redraw by + // making an invisible change (either adding or removing a dummy span). - @TargetApi(Build.VERSION_CODES.JELLY_BEAN) - private void api16Invalidate() { - if (initialized && !inInvalidate) { - inInvalidate = true; - setShadowLayer(getShadowRadius(), getShadowDx(), getShadowDy(), getShadowColor()); - inInvalidate = false; - } - } + Editable text = getText(); + if (text == null) return; - @Override - public void invalidate() { - //Need to force the TextView private mEditor variable to reset as well on API 16 and up - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { - api16Invalidate(); + int textLength = text.length(); + DummySpan[] dummySpans = text.getSpans(0, textLength, DummySpan.class); + if (dummySpans.length > 0) { + text.removeSpan(DummySpan.INSTANCE); + } else { + text.setSpan(DummySpan.INSTANCE, 0, textLength, Spannable.SPAN_INCLUSIVE_INCLUSIVE); } - - super.invalidate(); } @Override @@ -1158,10 +1151,16 @@ private void clearSelections() { if (text == null) return; TokenImageSpan[] tokens = text.getSpans(0, text.length(), TokenImageSpan.class); + boolean shouldRedrawTokens = false; for (TokenImageSpan token : tokens) { - token.view.setSelected(false); + if (token.view.isSelected()) { + token.view.setSelected(false); + shouldRedrawTokens = true; + } + } + if (shouldRedrawTokens) { + redrawTokens(); } - invalidate(); } protected class TokenImageSpan extends ViewSpan implements NoCopySpan { @@ -1190,12 +1189,13 @@ public void onClick() { if (!view.isSelected()) { clearSelections(); view.setSelected(true); + redrawTokens(); break; } if (tokenClickStyle == TokenClickStyle.SelectDeselect || !isTokenRemovable(token)) { view.setSelected(false); - invalidate(); + redrawTokens(); break; } //If the view is already selected, we want to delete it From f13bad4d00b44b8c741981febc751a7b3df0963c Mon Sep 17 00:00:00 2001 From: toppk Date: Tue, 9 Feb 2021 09:37:11 -0500 Subject: [PATCH 06/33] make Range public because it is part of Tokenizer public interface. --- library/src/main/java/com/tokenautocomplete/Range.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/src/main/java/com/tokenautocomplete/Range.java b/library/src/main/java/com/tokenautocomplete/Range.java index aa744697..091f9362 100644 --- a/library/src/main/java/com/tokenautocomplete/Range.java +++ b/library/src/main/java/com/tokenautocomplete/Range.java @@ -2,7 +2,7 @@ import java.util.Locale; -class Range { +public class Range { public final int start; public final int end; From 1a29cd60b881964af37d3d045580a4b43b6bf1b5 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Thu, 12 Aug 2021 10:28:57 -0400 Subject: [PATCH 07/33] Update version to 4.0.0-beta02/26 --- gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 8c65bbec..5ee88004 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,8 +12,8 @@ # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -VERSION_NAME=4.0.0-beta01 -VERSION_CODE=25 +VERSION_NAME=4.0.0-beta02 +VERSION_CODE=26 GROUP=com.splitwise ANDROID_BUILD_MIN_SDK_VERSION=14 ANDROID_BUILD_TARGET_SDK_VERSION=28 From df8cda674ac48a94f51fb8048e20fc47571e57cf Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Fri, 13 Aug 2021 16:46:15 -0400 Subject: [PATCH 08/33] Convert library to Kotlin --- build.gradle | 2 + .../TestCleanTokenActivity.java | 6 + gradle.properties | 1 + library/build.gradle | 7 + .../tokenautocomplete/CharacterTokenizer.java | 121 -- .../tokenautocomplete/CharacterTokenizer.kt | 71 + .../java/com/tokenautocomplete/CountSpan.java | 44 - .../java/com/tokenautocomplete/CountSpan.kt | 33 + .../java/com/tokenautocomplete/DummySpan.java | 23 - .../java/com/tokenautocomplete/DummySpan.kt | 18 + .../FilteredArrayAdapter.java | 144 -- .../tokenautocomplete/FilteredArrayAdapter.kt | 139 ++ .../java/com/tokenautocomplete/HintSpan.java | 16 - .../java/com/tokenautocomplete/HintSpan.kt | 18 + .../java/com/tokenautocomplete/Range.java | 36 - .../main/java/com/tokenautocomplete/Range.kt | 41 + .../java/com/tokenautocomplete/SpanUtils.java | 64 - .../java/com/tokenautocomplete/SpanUtils.kt | 69 + .../com/tokenautocomplete/TagTokenizer.java | 113 -- .../com/tokenautocomplete/TagTokenizer.kt | 63 + .../TokenCompleteTextView.java | 1623 ----------------- .../TokenCompleteTextView.kt | 1523 ++++++++++++++++ .../{Tokenizer.java => Tokenizer.kt} | 19 +- .../java/com/tokenautocomplete/ViewSpan.java | 84 - .../java/com/tokenautocomplete/ViewSpan.kt | 76 + 25 files changed, 2074 insertions(+), 2280 deletions(-) delete mode 100644 library/src/main/java/com/tokenautocomplete/CharacterTokenizer.java create mode 100644 library/src/main/java/com/tokenautocomplete/CharacterTokenizer.kt delete mode 100644 library/src/main/java/com/tokenautocomplete/CountSpan.java create mode 100644 library/src/main/java/com/tokenautocomplete/CountSpan.kt delete mode 100644 library/src/main/java/com/tokenautocomplete/DummySpan.java create mode 100644 library/src/main/java/com/tokenautocomplete/DummySpan.kt delete mode 100644 library/src/main/java/com/tokenautocomplete/FilteredArrayAdapter.java create mode 100644 library/src/main/java/com/tokenautocomplete/FilteredArrayAdapter.kt delete mode 100644 library/src/main/java/com/tokenautocomplete/HintSpan.java create mode 100644 library/src/main/java/com/tokenautocomplete/HintSpan.kt delete mode 100644 library/src/main/java/com/tokenautocomplete/Range.java create mode 100644 library/src/main/java/com/tokenautocomplete/Range.kt delete mode 100644 library/src/main/java/com/tokenautocomplete/SpanUtils.java create mode 100644 library/src/main/java/com/tokenautocomplete/SpanUtils.kt delete mode 100644 library/src/main/java/com/tokenautocomplete/TagTokenizer.java create mode 100644 library/src/main/java/com/tokenautocomplete/TagTokenizer.kt delete mode 100644 library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.java create mode 100644 library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt rename library/src/main/java/com/tokenautocomplete/{Tokenizer.java => Tokenizer.kt} (72%) delete mode 100644 library/src/main/java/com/tokenautocomplete/ViewSpan.java create mode 100644 library/src/main/java/com/tokenautocomplete/ViewSpan.kt diff --git a/build.gradle b/build.gradle index 3b8ffcff..a3c979e0 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { + ext.kotlin_version = KOTLIN_VERSION repositories { mavenCentral() google() @@ -8,6 +9,7 @@ buildscript { } dependencies { classpath "com.android.tools.build:gradle:4.0.1" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/example/src/main/java/com/tokenautocompleteexample/TestCleanTokenActivity.java b/example/src/main/java/com/tokenautocompleteexample/TestCleanTokenActivity.java index 4473765f..9eff4def 100644 --- a/example/src/main/java/com/tokenautocompleteexample/TestCleanTokenActivity.java +++ b/example/src/main/java/com/tokenautocompleteexample/TestCleanTokenActivity.java @@ -9,6 +9,7 @@ import android.view.View; import android.view.ViewGroup; import android.widget.ArrayAdapter; +import android.widget.TabHost; import android.widget.TextView; import com.tokenautocomplete.FilteredArrayAdapter; @@ -31,6 +32,11 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); + TabHost tabs = (TabHost) findViewById(R.id.tabHost); + tabs.setup(); + tabs.addTab(tabs.newTabSpec("Contacts").setContent(R.id.contactsFrame).setIndicator("Contacts")); + tabs.addTab(tabs.newTabSpec("Composer").setContent(R.id.hashtagsFrame).setIndicator("Composer")); + people = new Person[]{ new Person("Marshall Weir", "marshall@example.com"), new Person("Margaret Smith", "margaret@example.com"), diff --git a/gradle.properties b/gradle.properties index 5ee88004..aa1dd2c4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,6 +19,7 @@ ANDROID_BUILD_MIN_SDK_VERSION=14 ANDROID_BUILD_TARGET_SDK_VERSION=28 ANDROID_BUILD_SDK_VERSION=28 ANDROIDX_VERSION=1.1.0 +KOTLIN_VERSION=1.5.21 POM_DESCRIPTION=Android Token AutoComplete EditText POM_URL=https://github.com/splitwise/TokenAutoComplete POM_SCM_URL=https://github.com/splitwise/TokenAutoComplete diff --git a/library/build.gradle b/library/build.gradle index 94c9b7eb..94841746 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -3,6 +3,8 @@ plugins { id("maven-publish") id("signing") } +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-parcelize' android { compileSdkVersion Integer.parseInt(project.ANDROID_BUILD_SDK_VERSION) @@ -39,6 +41,8 @@ dependencies { implementation("androidx.annotation:annotation:$ANDROIDX_VERSION") implementation("androidx.appcompat:appcompat:$ANDROIDX_VERSION") + implementation "androidx.core:core-ktx:+" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" } task libraryJavadocs(type: Javadoc) { @@ -120,3 +124,6 @@ publishing { signing { sign publishing.publications.maven } +repositories { + mavenCentral() +} diff --git a/library/src/main/java/com/tokenautocomplete/CharacterTokenizer.java b/library/src/main/java/com/tokenautocomplete/CharacterTokenizer.java deleted file mode 100644 index 24ca9c45..00000000 --- a/library/src/main/java/com/tokenautocomplete/CharacterTokenizer.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.tokenautocomplete; - -import android.os.Parcel; -import android.os.Parcelable; -import androidx.annotation.NonNull; -import android.text.SpannableString; -import android.text.Spanned; -import android.text.TextUtils; - -import java.util.ArrayList; -import java.util.List; - -/** - * Tokenizer with configurable array of characters to tokenize on. - * - * Created on 2/3/15. - * @author mgod - */ -public class CharacterTokenizer implements Tokenizer { - private ArrayList splitChar; - private String tokenTerminator; - - @SuppressWarnings("WeakerAccess") - public CharacterTokenizer(List splitChar, String tokenTerminator){ - super(); - this.splitChar = new ArrayList<>(splitChar); - this.tokenTerminator = tokenTerminator; - } - - @Override - public boolean containsTokenTerminator(CharSequence charSequence) { - for (int i = 0; i < charSequence.length(); ++i) { - if (splitChar.contains(charSequence.charAt(i))) { - return true; - } - } - return false; - } - - @Override - @NonNull - public List findTokenRanges(CharSequence charSequence, int start, int end) { - ArrayListresult = new ArrayList<>(); - - if (start == end) { - //Can't have a 0 length token - return result; - } - - int tokenStart = start; - - for (int cursor = start; cursor < end; ++cursor) { - char character = charSequence.charAt(cursor); - - //Avoid including leading whitespace, tokenStart will match the cursor as long as we're at the start - if (tokenStart == cursor && Character.isWhitespace(character)) { - tokenStart = cursor + 1; - } - - //Either this is a split character, or we contain some content and are at the end of input - if (splitChar.contains(character) || cursor == end - 1) { - boolean hasTokenContent = - //There is token content befor the current character - cursor > tokenStart || - //If the current single character is valid token content, not a split char or whitespace - (cursor == tokenStart && !splitChar.contains(character)); - if (hasTokenContent) { - //There is some token content - //Add one to range end as the end of the ranges is not inclusive - result.add(new Range(tokenStart, cursor + 1)); - } - - tokenStart = cursor + 1; - } - } - - return result; - } - - @Override - @NonNull - public CharSequence wrapTokenValue(CharSequence text) { - CharSequence wrappedText = text + tokenTerminator; - - if (text instanceof Spanned) { - SpannableString sp = new SpannableString(wrappedText); - TextUtils.copySpansFrom((Spanned) text, 0, text.length(), - Object.class, sp, 0); - return sp; - } else { - return wrappedText; - } - } - - public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { - @SuppressWarnings("unchecked") - public CharacterTokenizer createFromParcel(Parcel in) { - return new CharacterTokenizer(in); - } - - public CharacterTokenizer[] newArray(int size) { - return new CharacterTokenizer[size]; - } - }; - - @Override - public int describeContents() { - return 0; - } - - @SuppressWarnings({"WeakerAccess", "unchecked"}) - CharacterTokenizer(Parcel in) { - this(in.readArrayList(Character.class.getClassLoader()), in.readString()); - } - - @Override - public void writeToParcel(Parcel parcel, int i) { - parcel.writeList(splitChar); - parcel.writeString(tokenTerminator); - } -} diff --git a/library/src/main/java/com/tokenautocomplete/CharacterTokenizer.kt b/library/src/main/java/com/tokenautocomplete/CharacterTokenizer.kt new file mode 100644 index 00000000..f840e110 --- /dev/null +++ b/library/src/main/java/com/tokenautocomplete/CharacterTokenizer.kt @@ -0,0 +1,71 @@ +package com.tokenautocomplete + +import android.text.SpannableString +import android.text.Spanned +import android.text.TextUtils +import kotlinx.parcelize.Parcelize +import java.util.* + +/** + * Tokenizer with configurable array of characters to tokenize on. + * + * Created on 2/3/15. + * @author mgod + */ +@Parcelize +open class CharacterTokenizer(private val splitChar: List, private val tokenTerminator: String) : Tokenizer { + + override fun containsTokenTerminator(charSequence: CharSequence): Boolean { + for (element in charSequence) { + if (splitChar.contains(element)) { + return true + } + } + return false + } + + override fun findTokenRanges(charSequence: CharSequence, start: Int, end: Int): List { + val result = ArrayList() + if (start == end) { + //Can't have a 0 length token + return result + } + var tokenStart = start + for (cursor in start until end) { + val character = charSequence[cursor] + + //Avoid including leading whitespace, tokenStart will match the cursor as long as we're at the start + if (tokenStart == cursor && Character.isWhitespace(character)) { + tokenStart = cursor + 1 + } + + //Either this is a split character, or we contain some content and are at the end of input + if (splitChar.contains(character) || cursor == end - 1) { + val hasTokenContent = //There is token content befor the current character + cursor > tokenStart || //If the current single character is valid token content, not a split char or whitespace + cursor == tokenStart && !splitChar.contains(character) + if (hasTokenContent) { + //There is some token content + //Add one to range end as the end of the ranges is not inclusive + result.add(Range(tokenStart, cursor + 1)) + } + tokenStart = cursor + 1 + } + } + return result + } + + override fun wrapTokenValue(unwrappedTokenValue: CharSequence): CharSequence { + val wrappedText: CharSequence = unwrappedTokenValue.toString() + tokenTerminator + return if (unwrappedTokenValue is Spanned) { + val sp = SpannableString(wrappedText) + TextUtils.copySpansFrom( + unwrappedTokenValue, 0, unwrappedTokenValue.length, + Any::class.java, sp, 0 + ) + sp + } else { + wrappedText + } + } +} \ No newline at end of file diff --git a/library/src/main/java/com/tokenautocomplete/CountSpan.java b/library/src/main/java/com/tokenautocomplete/CountSpan.java deleted file mode 100644 index 241942ba..00000000 --- a/library/src/main/java/com/tokenautocomplete/CountSpan.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.tokenautocomplete; - -import android.text.Layout; -import android.text.TextPaint; -import android.text.style.CharacterStyle; - -import java.util.Locale; - -/** - * Span that displays +[x] - * - * Created on 2/3/15. - * @author mgod - */ - -class CountSpan extends CharacterStyle { - private String countText; - - CountSpan() { - super(); - countText = ""; - } - - @Override - public void updateDrawState(TextPaint textPaint) { - //Do nothing, we are using this span as a location marker - } - - void setCount(int c) { - if (c > 0) { - countText = String.format(Locale.getDefault(), " +%d", c); - } else { - countText = ""; - } - } - - String getCountText() { - return countText; - } - - float getCountTextWidthForPaint(TextPaint paint) { - return Layout.getDesiredWidth(countText, 0, countText.length(), paint); - } -} diff --git a/library/src/main/java/com/tokenautocomplete/CountSpan.kt b/library/src/main/java/com/tokenautocomplete/CountSpan.kt new file mode 100644 index 00000000..5df50c1c --- /dev/null +++ b/library/src/main/java/com/tokenautocomplete/CountSpan.kt @@ -0,0 +1,33 @@ +package com.tokenautocomplete + +import android.text.Layout +import android.text.TextPaint +import android.text.style.CharacterStyle +import java.util.* + +/** + * Span that displays +[count] + * + * Created on 2/3/15. + * @author mgod + */ +class CountSpan : CharacterStyle() { + var countText = "" + private set + + override fun updateDrawState(textPaint: TextPaint) { + //Do nothing, we are using this span as a location marker + } + + fun setCount(c: Int) { + countText = if (c > 0) { + String.format(Locale.getDefault(), " +%d", c) + } else { + "" + } + } + + fun getCountTextWidthForPaint(paint: TextPaint?): Float { + return Layout.getDesiredWidth(countText, 0, countText.length, paint) + } +} \ No newline at end of file diff --git a/library/src/main/java/com/tokenautocomplete/DummySpan.java b/library/src/main/java/com/tokenautocomplete/DummySpan.java deleted file mode 100644 index b4e75a6b..00000000 --- a/library/src/main/java/com/tokenautocomplete/DummySpan.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.tokenautocomplete; - -import android.text.TextPaint; -import android.text.style.MetricAffectingSpan; - -import androidx.annotation.NonNull; - -/** - * Invisible MetricAffectingSpan that will trigger a redraw when it is being added to or removed from an Editable. - * - * @see TokenCompleteTextView#redrawTokens() - */ -class DummySpan extends MetricAffectingSpan { - static final DummySpan INSTANCE = new DummySpan(); - - private DummySpan() {} - - @Override - public void updateMeasureState(@NonNull TextPaint textPaint) {} - - @Override - public void updateDrawState(TextPaint tp) {} -} diff --git a/library/src/main/java/com/tokenautocomplete/DummySpan.kt b/library/src/main/java/com/tokenautocomplete/DummySpan.kt new file mode 100644 index 00000000..3078f0c9 --- /dev/null +++ b/library/src/main/java/com/tokenautocomplete/DummySpan.kt @@ -0,0 +1,18 @@ +package com.tokenautocomplete + +import android.text.TextPaint +import android.text.style.MetricAffectingSpan + +/** + * Invisible MetricAffectingSpan that will trigger a redraw when it is being added to or removed from an Editable. + * + * @see TokenCompleteTextView.redrawTokens + */ +internal class DummySpan private constructor() : MetricAffectingSpan() { + override fun updateMeasureState(textPaint: TextPaint) {} + override fun updateDrawState(tp: TextPaint) {} + + companion object { + val INSTANCE = DummySpan() + } +} \ No newline at end of file diff --git a/library/src/main/java/com/tokenautocomplete/FilteredArrayAdapter.java b/library/src/main/java/com/tokenautocomplete/FilteredArrayAdapter.java deleted file mode 100644 index 93543c37..00000000 --- a/library/src/main/java/com/tokenautocomplete/FilteredArrayAdapter.java +++ /dev/null @@ -1,144 +0,0 @@ -package com.tokenautocomplete; - -import android.content.Context; -import androidx.annotation.NonNull; -import android.widget.ArrayAdapter; -import android.widget.Filter; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; - -/** - * Simplified custom filtered ArrayAdapter - * override keepObject with your test for filtering - *

- * Based on gist - * FilteredArrayAdapter by Tobias Schürg - *

- * Created on 9/17/13. - * @author mgod - */ - -abstract public class FilteredArrayAdapter extends ArrayAdapter { - - private List originalObjects; - private Filter filter; - - /** - * Constructor - * - * @param context The current context. - * @param resource The resource ID for a layout file containing a TextView to use when - * instantiating views. - * @param objects The objects to represent in the ListView. - */ - public FilteredArrayAdapter(Context context, int resource, T[] objects) { - this(context, resource, 0, objects); - } - - /** - * Constructor - * - * @param context The current context. - * @param resource The resource ID for a layout file containing a layout to use when - * instantiating views. - * @param textViewResourceId The id of the TextView within the layout resource to be populated - * @param objects The objects to represent in the ListView. - */ - @SuppressWarnings("WeakerAccess") - public FilteredArrayAdapter(Context context, int resource, int textViewResourceId, T[] objects) { - this(context, resource, textViewResourceId, new ArrayList<>(Arrays.asList(objects))); - } - - /** - * Constructor - * - * @param context The current context. - * @param resource The resource ID for a layout file containing a TextView to use when - * instantiating views. - * @param objects The objects to represent in the ListView. - */ - @SuppressWarnings("unused") - public FilteredArrayAdapter(Context context, int resource, List objects) { - this(context, resource, 0, objects); - } - - /** - * Constructor - * - * @param context The current context. - * @param resource The resource ID for a layout file containing a layout to use when - * instantiating views. - * @param textViewResourceId The id of the TextView within the layout resource to be populated - * @param objects The objects to represent in the ListView. - */ - @SuppressWarnings("WeakerAccess") - public FilteredArrayAdapter(Context context, int resource, int textViewResourceId, List objects) { - super(context, resource, textViewResourceId, new ArrayList<>(objects)); - this.originalObjects = objects; - } - - @NonNull - @Override - public Filter getFilter() { - if (filter == null) - filter = new AppFilter(); - return filter; - } - - /** - * Filter method used by the adapter. Return true if the object should remain in the list - * - * @param obj object we are checking for inclusion in the adapter - * @param mask current text in the edit text we are completing against - * @return true if we should keep the item in the adapter - */ - abstract protected boolean keepObject(T obj, String mask); - - /** - * Class for filtering Adapter, relies on keepObject in FilteredArrayAdapter - * - * based on gist by Tobias Schürg - * in turn inspired by inspired by Alxandr - * (http://stackoverflow.com/a/2726348/570168) - */ - private class AppFilter extends Filter { - - @Override - protected FilterResults performFiltering(CharSequence chars) { - ArrayList sourceObjects = new ArrayList<>(originalObjects); - - FilterResults result = new FilterResults(); - if (chars != null && chars.length() > 0) { - String mask = chars.toString(); - ArrayList keptObjects = new ArrayList<>(); - - for (T object : sourceObjects) { - if (keepObject(object, mask)) - keptObjects.add(object); - } - result.count = keptObjects.size(); - result.values = keptObjects; - } else { - // add all objects - result.values = sourceObjects; - result.count = sourceObjects.size(); - } - return result; - } - - @SuppressWarnings("unchecked") - @Override - protected void publishResults(CharSequence constraint, FilterResults results) { - clear(); - if (results.count > 0) { - FilteredArrayAdapter.this.addAll((Collection)results.values); - notifyDataSetChanged(); - } else { - notifyDataSetInvalidated(); - } - } - } -} diff --git a/library/src/main/java/com/tokenautocomplete/FilteredArrayAdapter.kt b/library/src/main/java/com/tokenautocomplete/FilteredArrayAdapter.kt new file mode 100644 index 00000000..d7712112 --- /dev/null +++ b/library/src/main/java/com/tokenautocomplete/FilteredArrayAdapter.kt @@ -0,0 +1,139 @@ +package com.tokenautocomplete + +import android.content.Context +import android.widget.ArrayAdapter +import android.widget.Filter +import java.util.* + +/** + * Simplified custom filtered ArrayAdapter + * override keepObject with your test for filtering + * + * + * Based on gist [ + * FilteredArrayAdapter](https://gist.github.com/tobiasschuerg/3554252/raw/30634bf9341311ac6ad6739ef094222fc5f07fa8/FilteredArrayAdapter.java) by Tobias Schürg + * + * + * Created on 9/17/13. + * @author mgod + */ +abstract class FilteredArrayAdapter +/** + * Constructor + * + * @param context The current context. + * @param resource The resource ID for a layout file containing a layout to use when + * instantiating views. + * @param textViewResourceId The id of the TextView within the layout resource to be populated + * @param objects The objects to represent in the ListView. + */( + context: Context, + resource: Int, + textViewResourceId: Int, + objects: List +) : ArrayAdapter( + context, resource, textViewResourceId, ArrayList(objects) +) { + private val originalObjects: List = objects + private var filter: Filter? = null + + /** + * Constructor + * + * @param context The current context. + * @param resource The resource ID for a layout file containing a TextView to use when + * instantiating views. + * @param objects The objects to represent in the ListView. + */ + constructor(context: Context, resource: Int, objects: Array) : this( + context, + resource, + 0, + objects + ) + + /** + * Constructor + * + * @param context The current context. + * @param resource The resource ID for a layout file containing a layout to use when + * instantiating views. + * @param textViewResourceId The id of the TextView within the layout resource to be populated + * @param objects The objects to represent in the ListView. + */ + constructor( + context: Context, + resource: Int, + textViewResourceId: Int, + objects: Array + ) : this(context, resource, textViewResourceId, ArrayList(listOf(*objects))) + + /** + * Constructor + * + * @param context The current context. + * @param resource The resource ID for a layout file containing a TextView to use when + * instantiating views. + * @param objects The objects to represent in the ListView. + */ + @Suppress("unused") + constructor(context: Context, resource: Int, objects: List) : this( + context, + resource, + 0, + objects + ) + + override fun getFilter(): Filter { + if (filter == null) filter = AppFilter() + return filter!! + } + + /** + * Filter method used by the adapter. Return true if the object should remain in the list + * + * @param obj object we are checking for inclusion in the adapter + * @param mask current text in the edit text we are completing against + * @return true if we should keep the item in the adapter + */ + protected abstract fun keepObject(obj: T, mask: String?): Boolean + + /** + * Class for filtering Adapter, relies on keepObject in FilteredArrayAdapter + * + * based on gist by Tobias Schürg + * in turn inspired by inspired by Alxandr + * (http://stackoverflow.com/a/2726348/570168) + */ + private inner class AppFilter : Filter() { + override fun performFiltering(chars: CharSequence?): FilterResults { + val sourceObjects = ArrayList(originalObjects) + val result = FilterResults() + if (chars != null && chars.isNotEmpty()) { + val mask = chars.toString() + val keptObjects = ArrayList() + for (sourceObject in sourceObjects) { + if (keepObject(sourceObject, mask)) keptObjects.add(sourceObject) + } + result.count = keptObjects.size + result.values = keptObjects + } else { + // add all objects + result.values = sourceObjects + result.count = sourceObjects.size + } + return result + } + + override fun publishResults(constraint: CharSequence?, results: FilterResults) { + clear() + if (results.count > 0) { + @Suppress("unchecked_cast") + this@FilteredArrayAdapter.addAll(results.values as Collection) + notifyDataSetChanged() + } else { + notifyDataSetInvalidated() + } + } + } +} \ No newline at end of file diff --git a/library/src/main/java/com/tokenautocomplete/HintSpan.java b/library/src/main/java/com/tokenautocomplete/HintSpan.java deleted file mode 100644 index 2ba00e72..00000000 --- a/library/src/main/java/com/tokenautocomplete/HintSpan.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.tokenautocomplete; - -import android.content.res.ColorStateList; -import android.text.style.TextAppearanceSpan; - -/** - * Subclass of TextAppearanceSpan just to work with how Spans get detected - * - * Created on 2/3/15. - * @author mgod - */ -class HintSpan extends TextAppearanceSpan { - HintSpan(String family, int style, int size, ColorStateList color, ColorStateList linkColor) { - super(family, style, size, color, linkColor); - } -} diff --git a/library/src/main/java/com/tokenautocomplete/HintSpan.kt b/library/src/main/java/com/tokenautocomplete/HintSpan.kt new file mode 100644 index 00000000..77f07d0a --- /dev/null +++ b/library/src/main/java/com/tokenautocomplete/HintSpan.kt @@ -0,0 +1,18 @@ +package com.tokenautocomplete + +import android.content.res.ColorStateList +import android.text.style.TextAppearanceSpan + +/** + * Subclass of TextAppearanceSpan just to work with how Spans get detected + * + * Created on 2/3/15. + * @author mgod + */ +internal class HintSpan( + family: String?, + style: Int, + size: Int, + color: ColorStateList?, + linkColor: ColorStateList? +) : TextAppearanceSpan(family, style, size, color, linkColor) \ No newline at end of file diff --git a/library/src/main/java/com/tokenautocomplete/Range.java b/library/src/main/java/com/tokenautocomplete/Range.java deleted file mode 100644 index 091f9362..00000000 --- a/library/src/main/java/com/tokenautocomplete/Range.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.tokenautocomplete; - -import java.util.Locale; - -public class Range { - public final int start; - public final int end; - - Range(int start, int end) { - if (start > end) { - throw new IllegalArgumentException(String.format(Locale.ENGLISH, - "Start (%d) cannot be greater than end (%d)", start, end)); - } - this.start = start; - this.end = end; - } - - public int length() { - return end - start; - } - - @Override - public boolean equals(Object obj) { - if (null == obj || !(obj instanceof Range)) { - return false; - } - - Range other = (Range) obj; - return other.start == start && other.end == end; - } - - @Override - public String toString() { - return String.format(Locale.US, "[%d..%d]", start, end); - } -} diff --git a/library/src/main/java/com/tokenautocomplete/Range.kt b/library/src/main/java/com/tokenautocomplete/Range.kt new file mode 100644 index 00000000..e05ac58a --- /dev/null +++ b/library/src/main/java/com/tokenautocomplete/Range.kt @@ -0,0 +1,41 @@ +package com.tokenautocomplete + +import java.util.* + +class Range(start: Int, end: Int) { + @JvmField + val start: Int + @JvmField + val end: Int + fun length(): Int { + return end - start + } + + override fun equals(other: Any?): Boolean { + if (null == other || other !is Range) { + return false + } + return other.start == start && other.end == end + } + + override fun toString(): String { + return String.format(Locale.US, "[%d..%d]", start, end) + } + + override fun hashCode(): Int { + var result = start + result = 31 * result + end + return result + } + + init { + require(start <= end) { + String.format( + Locale.ENGLISH, + "Start (%d) cannot be greater than end (%d)", start, end + ) + } + this.start = start + this.end = end + } +} \ No newline at end of file diff --git a/library/src/main/java/com/tokenautocomplete/SpanUtils.java b/library/src/main/java/com/tokenautocomplete/SpanUtils.java deleted file mode 100644 index 11b76b51..00000000 --- a/library/src/main/java/com/tokenautocomplete/SpanUtils.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.tokenautocomplete; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.TextPaint; -import android.text.TextUtils; - -public class SpanUtils { - - private static class EllipsizeCallback implements TextUtils.EllipsizeCallback { - int start = 0; - int end = 0; - - @Override - public void ellipsized(int ellipsedStart, int ellipsedEnd) { - start = ellipsedStart; - end = ellipsedEnd; - } - } - - @Nullable - public static Spanned ellipsizeWithSpans(@Nullable CharSequence prefix, @Nullable CountSpan countSpan, - int tokenCount, @NonNull TextPaint paint, - @NonNull CharSequence originalText, float maxWidth) { - - float countWidth = 0; - if (countSpan != null) { - //Assume the largest possible number of items for measurement - countSpan.setCount(tokenCount); - countWidth = countSpan.getCountTextWidthForPaint(paint); - } - - EllipsizeCallback ellipsizeCallback = new EllipsizeCallback(); - CharSequence tempEllipsized = TextUtils.ellipsize(originalText, paint, maxWidth - countWidth, - TextUtils.TruncateAt.END, false, ellipsizeCallback); - SpannableStringBuilder ellipsized = new SpannableStringBuilder(tempEllipsized); - if (tempEllipsized instanceof Spanned) { - TextUtils.copySpansFrom((Spanned)tempEllipsized, 0, tempEllipsized.length(), Object.class, ellipsized, 0); - } - - if (prefix != null && prefix.length() > ellipsizeCallback.start) { - //We ellipsized part of the prefix, so put it back - ellipsized.replace(0, ellipsizeCallback.start, prefix); - ellipsizeCallback.end = ellipsizeCallback.end + prefix.length() - ellipsizeCallback.start; - ellipsizeCallback.start = prefix.length(); - } - - if (ellipsizeCallback.start != ellipsizeCallback.end) { - - if (countSpan != null) { - int visibleCount = ellipsized.getSpans(0, ellipsized.length(), TokenCompleteTextView.TokenImageSpan.class).length; - countSpan.setCount(tokenCount - visibleCount); - ellipsized.replace(ellipsizeCallback.start, ellipsized.length(), countSpan.getCountText()); - ellipsized.setSpan(countSpan, ellipsizeCallback.start, ellipsized.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - return ellipsized; - } - //No ellipses necessary - return null; - } -} diff --git a/library/src/main/java/com/tokenautocomplete/SpanUtils.kt b/library/src/main/java/com/tokenautocomplete/SpanUtils.kt new file mode 100644 index 00000000..d0fcb640 --- /dev/null +++ b/library/src/main/java/com/tokenautocomplete/SpanUtils.kt @@ -0,0 +1,69 @@ +package com.tokenautocomplete + +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.TextPaint +import android.text.TextUtils +import com.tokenautocomplete.TokenCompleteTextView.TokenImageSpan + +internal object SpanUtils { + @JvmStatic + fun ellipsizeWithSpans( + prefix: CharSequence?, countSpan: CountSpan?, + tokenCount: Int, paint: TextPaint, + originalText: CharSequence, maxWidth: Float + ): Spanned? { + var countWidth = 0f + if (countSpan != null) { + //Assume the largest possible number of items for measurement + countSpan.setCount(tokenCount) + countWidth = countSpan.getCountTextWidthForPaint(paint) + } + val ellipsizeCallback = EllipsizeCallback() + val tempEllipsized = TextUtils.ellipsize( + originalText, paint, maxWidth - countWidth, + TextUtils.TruncateAt.END, false, ellipsizeCallback + ) + val ellipsized = SpannableStringBuilder(tempEllipsized) + if (tempEllipsized is Spanned) { + TextUtils.copySpansFrom( + tempEllipsized, + 0, + tempEllipsized.length, + Any::class.java, + ellipsized, + 0 + ) + } + if (prefix != null && prefix.length > ellipsizeCallback.start) { + //We ellipsized part of the prefix, so put it back + ellipsized.replace(0, ellipsizeCallback.start, prefix) + ellipsizeCallback.end = ellipsizeCallback.end + prefix.length - ellipsizeCallback.start + ellipsizeCallback.start = prefix.length + } + if (ellipsizeCallback.start != ellipsizeCallback.end) { + if (countSpan != null) { + val visibleCount = + ellipsized.getSpans(0, ellipsized.length, TokenImageSpan::class.java).size + countSpan.setCount(tokenCount - visibleCount) + ellipsized.replace(ellipsizeCallback.start, ellipsized.length, countSpan.countText) + ellipsized.setSpan( + countSpan, ellipsizeCallback.start, ellipsized.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + return ellipsized + } + //No ellipses necessary + return null + } + + private class EllipsizeCallback : TextUtils.EllipsizeCallback { + var start = 0 + var end = 0 + override fun ellipsized(ellipsedStart: Int, ellipsedEnd: Int) { + start = ellipsedStart + end = ellipsedEnd + } + } +} \ No newline at end of file diff --git a/library/src/main/java/com/tokenautocomplete/TagTokenizer.java b/library/src/main/java/com/tokenautocomplete/TagTokenizer.java deleted file mode 100644 index c406b486..00000000 --- a/library/src/main/java/com/tokenautocomplete/TagTokenizer.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.tokenautocomplete; - -import android.os.Parcel; -import android.os.Parcelable; -import androidx.annotation.NonNull; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -@SuppressWarnings("unused") -public class TagTokenizer implements Tokenizer { - - private ArrayList tagPrefixes; - - TagTokenizer() { - this(Arrays.asList('@', '#')); - } - - public TagTokenizer(List tagPrefixes){ - super(); - this.tagPrefixes = new ArrayList<>(tagPrefixes); - } - - @SuppressWarnings("WeakerAccess") - protected boolean isTokenTerminator(char character) { - //Allow letters, numbers and underscores - return !Character.isLetterOrDigit(character) && character != '_'; - } - - @Override - public boolean containsTokenTerminator(CharSequence charSequence) { - for (int i = 0; i < charSequence.length(); ++i) { - if (isTokenTerminator(charSequence.charAt(i))) { - return true; - } - } - - return false; - } - - @Override - @NonNull - public List findTokenRanges(CharSequence charSequence, int start, int end) { - ArrayListresult = new ArrayList<>(); - - if (start == end) { - //Can't have a 0 length token - return result; - } - - int tokenStart = Integer.MAX_VALUE; - - for (int cursor = start; cursor < end; ++cursor) { - char character = charSequence.charAt(cursor); - - //Either this is a terminator, or we contain some content and are at the end of input - if (isTokenTerminator(character)) { - //Is there some token content? Might just be two terminators in a row - if (cursor - 1 > tokenStart) { - result.add(new Range(tokenStart, cursor)); - } - - //mark that we don't have a candidate token start any more - tokenStart = Integer.MAX_VALUE; - } - - //Set tokenStart when we hit a tag prefix - if (tagPrefixes.contains(character)) { - tokenStart = cursor; - } - } - - if (end > tokenStart) { - //There was unterminated text after a start of token - result.add(new Range(tokenStart, end)); - } - - return result; - } - - @Override - @NonNull - public CharSequence wrapTokenValue(CharSequence text) { - return text; - } - - public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { - @SuppressWarnings("unchecked") - public TagTokenizer createFromParcel(Parcel in) { - return new TagTokenizer(in); - } - - public TagTokenizer[] newArray(int size) { - return new TagTokenizer[size]; - } - }; - - @Override - public int describeContents() { - return 0; - } - - @SuppressWarnings({"WeakerAccess", "unchecked"}) - TagTokenizer(Parcel in) { - this(in.readArrayList(Character.class.getClassLoader())); - } - - @Override - public void writeToParcel(Parcel parcel, int i) { - parcel.writeList(tagPrefixes); - } -} diff --git a/library/src/main/java/com/tokenautocomplete/TagTokenizer.kt b/library/src/main/java/com/tokenautocomplete/TagTokenizer.kt new file mode 100644 index 00000000..44a0bf5c --- /dev/null +++ b/library/src/main/java/com/tokenautocomplete/TagTokenizer.kt @@ -0,0 +1,63 @@ +package com.tokenautocomplete + +import kotlinx.parcelize.Parcelize +import java.util.* + +@Parcelize +open class TagTokenizer constructor(private val tagPrefixes: List) : Tokenizer { + + internal constructor() : this(listOf('@', '#')) + + @Suppress("MemberVisibilityCanBePrivate") + protected fun isTokenTerminator(character: Char): Boolean { + //Allow letters, numbers and underscores + return !Character.isLetterOrDigit(character) && character != '_' + } + + override fun containsTokenTerminator(charSequence: CharSequence): Boolean { + for (element in charSequence) { + if (isTokenTerminator(element)) { + return true + } + } + return false + } + + override fun findTokenRanges(charSequence: CharSequence, start: Int, end: Int): List { + val result = ArrayList() + if (start == end) { + //Can't have a 0 length token + return result + } + var tokenStart = Int.MAX_VALUE + for (cursor in start until end) { + val character = charSequence[cursor] + + //Either this is a terminator, or we contain some content and are at the end of input + if (isTokenTerminator(character)) { + //Is there some token content? Might just be two terminators in a row + if (cursor - 1 > tokenStart) { + result.add(Range(tokenStart, cursor)) + } + + //mark that we don't have a candidate token start any more + tokenStart = Int.MAX_VALUE + } + + //Set tokenStart when we hit a tag prefix + if (tagPrefixes.contains(character)) { + tokenStart = cursor + } + } + if (end > tokenStart) { + //There was unterminated text after a start of token + result.add(Range(tokenStart, end)) + } + return result + } + + override fun wrapTokenValue(unwrappedTokenValue: CharSequence): CharSequence { + return unwrappedTokenValue + } + +} \ No newline at end of file diff --git a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.java b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.java deleted file mode 100644 index d4863678..00000000 --- a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.java +++ /dev/null @@ -1,1623 +0,0 @@ -package com.tokenautocomplete; - -import android.content.Context; -import android.content.res.ColorStateList; -import android.graphics.Rect; -import android.graphics.Typeface; -import android.os.Parcel; -import android.os.Parcelable; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.UiThread; -import androidx.appcompat.widget.AppCompatAutoCompleteTextView; -import android.text.Editable; -import android.text.InputFilter; -import android.text.InputType; -import android.text.Layout; -import android.text.NoCopySpan; -import android.text.Selection; -import android.text.SpanWatcher; -import android.text.Spannable; -import android.text.SpannableString; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.text.style.ForegroundColorSpan; -import android.util.AttributeSet; -import android.util.Log; -import android.view.KeyEvent; -import android.view.MotionEvent; -import android.view.View; -import android.view.accessibility.AccessibilityEvent; -import android.view.inputmethod.EditorInfo; -import android.view.inputmethod.ExtractedText; -import android.view.inputmethod.ExtractedTextRequest; -import android.view.inputmethod.InputConnection; -import android.view.inputmethod.InputConnectionWrapper; -import android.view.inputmethod.InputMethodManager; -import android.widget.Filter; -import android.widget.ListView; -import android.widget.TextView; - -import java.io.Serializable; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -/** - * GMail style auto complete view with easy token customization - * override getViewForObject to provide your token view - *
- * Created by mgod on 9/12/13. - * - * @author mgod - */ -public abstract class TokenCompleteTextView extends AppCompatAutoCompleteTextView - implements TextView.OnEditorActionListener, ViewSpan.Layout { - //Logging - public static final String TAG = "TokenAutoComplete"; - - //When the user clicks on a token... - public enum TokenClickStyle { - None(false), //...do nothing, but make sure the cursor is not in the token - Delete(false),//...delete the token - Select(true),//...select the token. A second click will delete it. - SelectDeselect(true); - - private boolean mIsSelectable; - - TokenClickStyle(final boolean selectable) { - mIsSelectable = selectable; - } - - public boolean isSelectable() { - return mIsSelectable; - } - } - - private Tokenizer tokenizer; - private T selectedObject; - private TokenListener listener; - private TokenSpanWatcher spanWatcher; - private TokenTextWatcher textWatcher; - private CountSpan countSpan; - private @Nullable SpannableStringBuilder hiddenContent; - private TokenClickStyle tokenClickStyle = TokenClickStyle.None; - private CharSequence prefix = ""; - private boolean hintVisible = false; - private Layout lastLayout = null; - private boolean initialized = false; - private boolean performBestGuess = true; - private boolean preventFreeFormText = true; - private boolean savingState = false; - private boolean shouldFocusNext = false; - private boolean allowCollapse = true; - private boolean internalEditInProgress = false; - - private int tokenLimit = -1; - - private transient String lastCompletionText = null; - - /** - * Add the TextChangedListeners - */ - protected void addListeners() { - Editable text = getText(); - if (text != null) { - text.setSpan(spanWatcher, 0, text.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); - addTextChangedListener(textWatcher); - } - } - - /** - * Remove the TextChangedListeners - */ - protected void removeListeners() { - Editable text = getText(); - if (text != null) { - TokenSpanWatcher[] spanWatchers = text.getSpans(0, text.length(), TokenSpanWatcher.class); - for (TokenSpanWatcher watcher : spanWatchers) { - text.removeSpan(watcher); - } - removeTextChangedListener(textWatcher); - } - } - - /** - * Initialise the variables and various listeners - */ - private void init() { - if (initialized) return; - - // Initialise variables - setTokenizer(new CharacterTokenizer(Arrays.asList(',', ';'), ",")); - Editable text = getText(); - assert null != text; - spanWatcher = new TokenSpanWatcher(); - textWatcher = new TokenTextWatcher(); - hiddenContent = null; - countSpan = new CountSpan(); - - // Initialise TextChangedListeners - addListeners(); - - setTextIsSelectable(false); - setLongClickable(false); - - //In theory, get the soft keyboard to not supply suggestions. very unreliable - setInputType(getInputType() | - InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS | - InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE); - setHorizontallyScrolling(false); - - // Listen to IME action keys - setOnEditorActionListener(this); - - // Initialise the text filter (listens for the split chars) - setFilters(new InputFilter[]{new InputFilter() { - @Override - public CharSequence filter(CharSequence source, int start, int end, - Spanned dest, int destinationStart, int destinationEnd) { - if (internalEditInProgress) { - return null; - } - - // Token limit check - if (tokenLimit != -1 && getObjects().size() == tokenLimit) { - return ""; - } - - //Detect split characters, remove them and complete the current token instead - if (tokenizer.containsTokenTerminator(source)) { - //Only perform completion if we don't allow free form text, or if there's enough - //content to believe this should be a token - if (preventFreeFormText || currentCompletionText().length() > 0) { - performCompletion(); - return ""; - } - } - - //We need to not do anything when we would delete the prefix - if (destinationStart < prefix.length()) { - //when setText is called, which should only be called during restoring, - //destinationStart and destinationEnd are 0. If not checked, it will clear out - //the prefix. - //This is why we need to return null in this if condition to preserve state. - if (destinationStart == 0 && destinationEnd == 0) { - return null; - } else if (destinationEnd <= prefix.length()) { - //Don't do anything - return prefix.subSequence(destinationStart, destinationEnd); - } else { - //Delete everything up to the prefix - return prefix.subSequence(destinationStart, prefix.length()); - } - } - return null; - } - }}); - - initialized = true; - } - - public TokenCompleteTextView(Context context) { - super(context); - init(); - } - - public TokenCompleteTextView(Context context, AttributeSet attrs) { - super(context, attrs); - init(); - } - - public TokenCompleteTextView(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - init(); - } - - @Override - protected void performFiltering(CharSequence text, int keyCode) { - Filter filter = getFilter(); - if (filter != null) { - filter.filter(currentCompletionText(), this); - } - } - - public void setTokenizer(Tokenizer t) { - tokenizer = t; - } - - /** - * Set the action to be taken when a Token is clicked - * - * @param cStyle The TokenClickStyle - */ - public void setTokenClickStyle(TokenClickStyle cStyle) { - tokenClickStyle = cStyle; - } - - /** - * Set the listener that will be notified of changes in the Token list - * - * @param l The TokenListener - */ - public void setTokenListener(TokenListener l) { - listener = l; - } - - /** - * Override if you want to prevent a token from being added. Defaults to false. - * @param token the token to check - * @return true if the token should not be added, false if it's ok to add it. - */ - public boolean shouldIgnoreToken(@SuppressWarnings("unused") T token) { - return false; - } - - /** - * Override if you want to prevent a token from being removed. Defaults to true. - * @param token the token to check - * @return false if the token should not be removed, true if it's ok to remove it. - */ - public boolean isTokenRemovable(@SuppressWarnings("unused") T token) { - return true; - } - - /** - * A String of text that is shown before all the tokens inside the EditText - * (Think "To: " in an email address field. I would advise against this: use a label and a hint. - * - * @param p String with the hint - */ - public void setPrefix(CharSequence p) { - //Have to clear and set the actual text before saving the prefix to avoid the prefix filter - CharSequence prevPrefix = prefix; - prefix = p; - Editable text = getText(); - if (text != null) { - internalEditInProgress = true; - if (prevPrefix != null) { - text.replace(0, prevPrefix.length(), p); - } else { - text.insert(0, p); - } - internalEditInProgress = false; - } - //prefix = p; - - updateHint(); - } - - /** - *

You can get a color integer either using - * {@link androidx.core.content.ContextCompat#getColor(android.content.Context, int)} - * or with {@link android.graphics.Color#parseColor(String)}.

- *

{@link android.graphics.Color#parseColor(String)} - * accepts these formats (copied from android.graphics.Color): - * You can use: '#RRGGBB', '#AARRGGBB' - * or one of the following names: 'red', 'blue', 'green', 'black', 'white', - * 'gray', 'cyan', 'magenta', 'yellow', 'lightgray', 'darkgray', 'grey', - * 'lightgrey', 'darkgrey', 'aqua', 'fuchsia', 'lime', 'maroon', 'navy', - * 'olive', 'purple', 'silver', 'teal'.

- * - * @param prefix prefix - * @param color A single color value in the form 0xAARRGGBB. - */ - @SuppressWarnings("SameParameterValue") - public void setPrefix(CharSequence prefix, int color) { - SpannableString spannablePrefix = new SpannableString(prefix); - spannablePrefix.setSpan(new ForegroundColorSpan(color), 0, spannablePrefix.length(), 0); - setPrefix(spannablePrefix); - } - - /** - * Get the list of Tokens - * - * @return List of tokens - */ - public List getObjects() { - ArrayListobjects = new ArrayList<>(); - Editable text = getText(); - if (hiddenContent != null) { - text = hiddenContent; - } - for (TokenImageSpan span: text.getSpans(0, text.length(), TokenImageSpan.class)) { - objects.add(span.getToken()); - } - return objects; - } - - /** - * Get the content entered in the text field, including hidden text when ellipsized - * - * @return CharSequence of the entered content - */ - public CharSequence getContentText() { - if (hiddenContent != null) { - return hiddenContent; - } else { - return getText(); - } - } - - /** - * Set whether we try to guess an entry from the autocomplete spinner or just use the - * defaultObject implementation for inline token completion. - * - * @param guess true to enable guessing - */ - public void performBestGuess(boolean guess) { - performBestGuess = guess; - } - - /** - * If set to true, the only content in this view will be the tokens and the current completion - * text. Use this setting to create things like lists of email addresses. If false, it the view - * will allow text in addition to tokens. Use this if you want to use the token search to find - * things like user names or hash tags to put in with text. - * - * @param prevent true to prevent non-token text. Defaults to true. - */ - public void preventFreeFormText(boolean prevent) { - preventFreeFormText = prevent; - } - - /** - * Set whether the view should collapse to a single line when it loses focus. - * - * @param allowCollapse true if it should collapse - */ - public void allowCollapse(boolean allowCollapse) { - this.allowCollapse = allowCollapse; - } - - /** - * Set a number of tokens limit. - * - * @param tokenLimit The number of tokens permitted. -1 value disables limit. - */ - @SuppressWarnings("unused") - public void setTokenLimit(int tokenLimit) { - this.tokenLimit = tokenLimit; - } - - /** - * A token view for the object - * - * @param object the object selected by the user from the list - * @return a view to display a token in the text field for the object - */ - abstract protected View getViewForObject(T object); - - /** - * Provides a default completion when the user hits , and there is no item in the completion - * list - * - * @param completionText the current text we are completing against - * @return a best guess for what the user meant to complete or null if you don't want a guess - */ - abstract protected T defaultObject(String completionText); - - /** - * Correctly build accessibility string for token contents - * - * This seems to be a hidden API, but there doesn't seem to be another reasonable way - * @return custom string for accessibility - */ - @SuppressWarnings("unused") - public CharSequence getTextForAccessibility() { - if (getObjects().size() == 0) { - return getText(); - } - - SpannableStringBuilder description = new SpannableStringBuilder(); - Editable text = getText(); - int selectionStart = -1; - int selectionEnd = -1; - int i; - //Need to take the existing tet buffer and - // - replace all tokens with a decent string representation of the object - // - set the selection span to the corresponding location in the new CharSequence - for (i = 0; i < text.length(); ++i) { - //See if this is where we should start the selection - int origSelectionStart = Selection.getSelectionStart(text); - if (i == origSelectionStart) { - selectionStart = description.length(); - } - int origSelectionEnd = Selection.getSelectionEnd(text); - if (i == origSelectionEnd) { - selectionEnd = description.length(); - } - - //Replace token spans - TokenImageSpan[] tokens = text.getSpans(i, i, TokenImageSpan.class); - if (tokens.length > 0) { - TokenImageSpan token = tokens[0]; - description = description.append(tokenizer.wrapTokenValue(token.getToken().toString())); - i = text.getSpanEnd(token); - continue; - } - - description = description.append(text.subSequence(i, i + 1)); - } - - int origSelectionStart = Selection.getSelectionStart(text); - if (i == origSelectionStart) { - selectionStart = description.length(); - } - int origSelectionEnd = Selection.getSelectionEnd(text); - if (i == origSelectionEnd) { - selectionEnd = description.length(); - } - - if (selectionStart >= 0 && selectionEnd >= 0) { - Selection.setSelection(description, selectionStart, selectionEnd); - } - - return description; - } - - /** - * Clear the completion text only. - */ - @SuppressWarnings("unused") - public void clearCompletionText() { - //Respect currentCompletionText in case hint is visible or if other checks are added. - if (currentCompletionText().length() == 0){ - return; - } - - Range currentRange = getCurrentCandidateTokenRange(); - internalEditInProgress = true; - getText().delete(currentRange.start, currentRange.end); - internalEditInProgress = false; - } - - @Override - public void onInitializeAccessibilityEvent(AccessibilityEvent event) { - super.onInitializeAccessibilityEvent(event); - - if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) { - CharSequence text = getTextForAccessibility(); - event.setFromIndex(Selection.getSelectionStart(text)); - event.setToIndex(Selection.getSelectionEnd(text)); - event.setItemCount(text.length()); - } - } - - private Range getCurrentCandidateTokenRange() { - Editable editable = getText(); - int cursorEndPosition = getSelectionEnd(); - int candidateStringStart = prefix.length(); - int candidateStringEnd = editable.length(); - if (hintVisible) { - //Don't try to search the hint for possible tokenizable strings - candidateStringEnd = candidateStringStart; - } - - //We want to find the largest string that contains the selection end that is not already tokenized - TokenImageSpan[] spans = editable.getSpans(prefix.length(), editable.length(), TokenImageSpan.class); - for (TokenImageSpan span : spans) { - int spanEnd = editable.getSpanEnd(span); - if (candidateStringStart < spanEnd && cursorEndPosition >= spanEnd) { - candidateStringStart = spanEnd; - } - int spanStart = editable.getSpanStart(span); - if (candidateStringEnd > spanStart && cursorEndPosition <= spanEnd) { - candidateStringEnd = spanStart; - } - } - - List tokenRanges = tokenizer.findTokenRanges(editable, candidateStringStart, candidateStringEnd); - - for (Range range: tokenRanges) { - if (range.start <= cursorEndPosition && cursorEndPosition <= range.end) { - return range; - } - } - - return new Range(cursorEndPosition, cursorEndPosition); - } - - /** - * Override if you need custom logic to provide a sting representation of a token - * @param token the token to convert - * @return the string representation of the token. Defaults to {@link Object#toString()} - */ - protected CharSequence tokenToString(T token) { - return token.toString(); - } - - protected String currentCompletionText() { - if (hintVisible) return ""; //Can't have any text if the hint is visible - - Editable editable = getText(); - Range currentRange = getCurrentCandidateTokenRange(); - - String result = TextUtils.substring(editable, currentRange.start, currentRange.end); - Log.d(TAG, "Current completion text: " + result); - return result; - } - - protected float maxTextWidth() { - return getWidth() - getPaddingLeft() - getPaddingRight(); - } - - @Override - public int getMaxViewSpanWidth() { - return (int)maxTextWidth(); - } - - public void redrawTokens() { - // There's no straight-forward way to convince the widget to redraw the text and spans. We trigger a redraw by - // making an invisible change (either adding or removing a dummy span). - - Editable text = getText(); - if (text == null) return; - - int textLength = text.length(); - DummySpan[] dummySpans = text.getSpans(0, textLength, DummySpan.class); - if (dummySpans.length > 0) { - text.removeSpan(DummySpan.INSTANCE); - } else { - text.setSpan(DummySpan.INSTANCE, 0, textLength, Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } - } - - @Override - public boolean enoughToFilter() { - if (tokenizer == null || hintVisible) { - return false; - } - - int cursorPosition = getSelectionEnd(); - - if (cursorPosition < 0) { - return false; - } - - Range currentCandidateRange = getCurrentCandidateTokenRange(); - - //Don't allow 0 length entries to filter - return currentCandidateRange.length() >= Math.max(getThreshold(), 1); - } - - @Override - public void performCompletion() { - if ((getAdapter() == null || getListSelection() == ListView.INVALID_POSITION) && enoughToFilter()) { - Object bestGuess; - if (getAdapter() != null && getAdapter().getCount() > 0 && performBestGuess) { - bestGuess = getAdapter().getItem(0); - } else { - bestGuess = defaultObject(currentCompletionText()); - } - replaceText(convertSelectionToString(bestGuess)); - } else { - super.performCompletion(); - } - } - - @Override - public InputConnection onCreateInputConnection(@NonNull EditorInfo outAttrs) { - InputConnection superConn = super.onCreateInputConnection(outAttrs); - if (superConn != null) { - TokenInputConnection conn = new TokenInputConnection(superConn, true); - outAttrs.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION; - outAttrs.imeOptions |= EditorInfo.IME_FLAG_NO_EXTRACT_UI; - return conn; - } else { - return null; - } - } - - /** - * Create a token and hide the keyboard when the user sends the DONE IME action - * Use IME_NEXT if you want to create a token and go to the next field - */ - private void handleDone() { - // Attempt to complete the current token token - performCompletion(); - - // Hide the keyboard - InputMethodManager imm = (InputMethodManager) getContext().getSystemService( - Context.INPUT_METHOD_SERVICE); - if (imm != null) { - imm.hideSoftInputFromWindow(getWindowToken(), 0); - } - } - - @Override - public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) { - boolean handled = super.onKeyUp(keyCode, event); - if (shouldFocusNext) { - shouldFocusNext = false; - handleDone(); - } - return handled; - } - - @Override - public boolean onKeyDown(int keyCode, @NonNull KeyEvent event) { - boolean handled = false; - switch (keyCode) { - case KeyEvent.KEYCODE_TAB: - case KeyEvent.KEYCODE_ENTER: - case KeyEvent.KEYCODE_DPAD_CENTER: - if (event.hasNoModifiers()) { - shouldFocusNext = true; - handled = true; - } - break; - case KeyEvent.KEYCODE_DEL: - handled = !canDeleteSelection(1) || deleteSelectedObject(); - break; - } - - return handled || super.onKeyDown(keyCode, event); - } - - private boolean deleteSelectedObject() { - if (tokenClickStyle != null && tokenClickStyle.isSelectable()) { - Editable text = getText(); - if (text == null) return false; - - TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class); - for (TokenImageSpan span : spans) { - if (span.view.isSelected()) { - removeSpan(text, span); - return true; - } - } - } - return false; - } - - @Override - public boolean onEditorAction(TextView view, int action, KeyEvent keyEvent) { - if (action == EditorInfo.IME_ACTION_DONE) { - handleDone(); - return true; - } - return false; - } - - @Override - public boolean onTouchEvent(@NonNull MotionEvent event) { - int action = event.getActionMasked(); - Editable text = getText(); - boolean handled = false; - - if (tokenClickStyle == TokenClickStyle.None) { - handled = super.onTouchEvent(event); - } - - if (isFocused() && text != null && lastLayout != null && action == MotionEvent.ACTION_UP) { - - int offset = getOffsetForPosition(event.getX(), event.getY()); - - if (offset != -1) { - TokenImageSpan[] links = text.getSpans(offset, offset, TokenImageSpan.class); - - if (links.length > 0) { - links[0].onClick(); - handled = true; - } else { - //We didn't click on a token, so if any are selected, we should clear that - clearSelections(); - } - } - } - - if (!handled && tokenClickStyle != TokenClickStyle.None) { - handled = super.onTouchEvent(event); - } - return handled; - - } - - @Override - protected void onSelectionChanged(int selStart, int selEnd) { - if (hintVisible) { - //Don't let users select the hint - selStart = 0; - } - //Never let users select text - selEnd = selStart; - - if (tokenClickStyle != null && tokenClickStyle.isSelectable()) { - Editable text = getText(); - if (text != null) { - clearSelections(); - } - } - - - if (prefix != null && (selStart < prefix.length() || selEnd < prefix.length())) { - //Don't let users select the prefix - setSelection(prefix.length()); - } else { - Editable text = getText(); - if (text != null) { - //Make sure if we are in a span, we select the spot 1 space after the span end - TokenImageSpan[] spans = text.getSpans(selStart, selEnd, TokenImageSpan.class); - for (TokenImageSpan span : spans) { - int spanEnd = text.getSpanEnd(span); - if (selStart <= spanEnd && text.getSpanStart(span) < selStart) { - if (spanEnd == text.length()) - setSelection(spanEnd); - else - setSelection(spanEnd + 1); - return; - } - } - - } - - super.onSelectionChanged(selStart, selEnd); - } - } - - @Override - protected void onLayout(boolean changed, int left, int top, int right, int bottom) { - super.onLayout(changed, left, top, right, bottom); - lastLayout = getLayout(); //Used for checking text positions - } - - /** - * Collapse the view by removing all the tokens not on the first line. Displays a "+x" token. - * Restores the hidden tokens when the view gains focus. - * - * @param hasFocus boolean indicating whether we have the focus or not. - */ - public void performCollapse(boolean hasFocus) { - internalEditInProgress = true; - if (!hasFocus) { - // Display +x thingy/ellipse if appropriate - final Editable text = getText(); - if (text != null && hiddenContent == null && lastLayout != null) { - - //Ellipsize copies spans, so we need to stop listening to span changes here - text.removeSpan(spanWatcher); - - CountSpan temp = preventFreeFormText ? countSpan : null; - Spanned ellipsized = SpanUtils.ellipsizeWithSpans(prefix, temp, getObjects().size(), - lastLayout.getPaint(), text, maxTextWidth()); - - if (ellipsized != null) { - hiddenContent = new SpannableStringBuilder(text); - setText(ellipsized); - TextUtils.copySpansFrom(ellipsized, 0, ellipsized.length(), - TokenImageSpan.class, getText(), 0); - TextUtils.copySpansFrom(text, 0, hiddenContent.length(), - TokenImageSpan.class, hiddenContent, 0); - hiddenContent.setSpan(spanWatcher, 0, hiddenContent.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); - } else { - getText().setSpan(spanWatcher, 0, getText().length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); - } - } - } else { - if (hiddenContent != null) { - setText(hiddenContent); - TextUtils.copySpansFrom(hiddenContent, 0, hiddenContent.length(), - TokenImageSpan.class, getText(), 0); - hiddenContent = null; - - if (hintVisible) { - setSelection(prefix.length()); - } else { - post(new Runnable() { - @Override - public void run() { - setSelection(getText().length()); - } - }); - } - - TokenSpanWatcher[] watchers = getText().getSpans(0, getText().length(), TokenSpanWatcher.class); - if (watchers.length == 0) { - //Span watchers can get removed in setText - getText().setSpan(spanWatcher, 0, getText().length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); - } - } - } - internalEditInProgress = false; - } - - @Override - public void onFocusChanged(boolean hasFocus, int direction, Rect previous) { - super.onFocusChanged(hasFocus, direction, previous); - - // Clear sections when focus changes to avoid a token remaining selected - clearSelections(); - - // Collapse the view to a single line - if (allowCollapse) performCollapse(hasFocus); - } - - @SuppressWarnings("unchecked cast") - @Override - protected CharSequence convertSelectionToString(Object object) { - selectedObject = (T) object; - return ""; - } - - protected TokenImageSpan buildSpanForObject(T obj) { - if (obj == null) { - return null; - } - View tokenView = getViewForObject(obj); - return new TokenImageSpan(tokenView, obj); - } - - @Override - protected void replaceText(CharSequence ignore) { - clearComposingText(); - - // Don't build a token for an empty String - if (selectedObject == null || selectedObject.toString().equals("")) return; - - TokenImageSpan tokenSpan = buildSpanForObject(selectedObject); - - Editable editable = getText(); - Range candidateRange = getCurrentCandidateTokenRange(); - - String original = TextUtils.substring(editable, candidateRange.start, candidateRange.end); - - //Keep track of replacements for a bug workaround - if (original.length() > 0) { - lastCompletionText = original; - } - - if (editable != null) { - internalEditInProgress = true; - if (tokenSpan == null) { - editable.replace(candidateRange.start, candidateRange.end, ""); - } else if (shouldIgnoreToken(tokenSpan.getToken())) { - editable.replace(candidateRange.start, candidateRange.end, ""); - if (listener != null) { - listener.onTokenIgnored(tokenSpan.getToken()); - } - } else { - SpannableStringBuilder ssb = new SpannableStringBuilder(tokenizer.wrapTokenValue(tokenToString(tokenSpan.token))); - editable.replace(candidateRange.start, candidateRange.end, ssb); - editable.setSpan(tokenSpan, candidateRange.start, candidateRange.start + ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - editable.insert(candidateRange.start + ssb.length(), " "); - } - internalEditInProgress = false; - } - } - - @Override - public boolean extractText(@NonNull ExtractedTextRequest request, @NonNull ExtractedText outText) { - try { - return super.extractText(request, outText); - } catch (IndexOutOfBoundsException ex) { - Log.d(TAG, "extractText hit IndexOutOfBoundsException. This may be normal.", ex); - return false; - } - } - - /** - * Append a token object to the object list. May only be called from the main thread. - * - * @param object the object to add to the displayed tokens - */ - @UiThread - public void addObjectSync(T object) { - if (object == null) return; - if (shouldIgnoreToken(object)) { - if (listener != null) { - listener.onTokenIgnored(object); - } - return; - } - if (tokenLimit != -1 && getObjects().size() == tokenLimit) return; - insertSpan(buildSpanForObject(object)); - if (getText() != null && isFocused()) setSelection(getText().length()); - } - - /** - * Append a token object to the object list. Object will be added on the main thread. - * - * @param object the object to add to the displayed tokens - */ - public void addObjectAsync(final T object) { - post(new Runnable() { - @Override - public void run() { - addObjectSync(object); - } - }); - } - - /** - * Remove an object from the token list. Will remove duplicates if present or do nothing if no - * object is present in the view. Uses {@link Object#equals(Object)} to find objects. May only - * be called from the main thread - * - * @param object object to remove, may be null or not in the view - */ - @UiThread - public void removeObjectSync(T object) { - //To make sure all the appropriate callbacks happen, we just want to piggyback on the - //existing code that handles deleting spans when the text changes - ArrayListtexts = new ArrayList<>(); - //If there is hidden content, it's important that we update it first - if (hiddenContent != null) { - texts.add(hiddenContent); - } - if (getText() != null) { - texts.add(getText()); - } - - // If the object is currently visible, remove it - for (Editable text: texts) { - TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class); - for (TokenImageSpan span : spans) { - if (span.getToken().equals(object)) { - removeSpan(text, span); - } - } - } - - updateCountSpan(); - } - - /** - * Remove an object from the token list. Will remove duplicates if present or do nothing if no - * object is present in the view. Uses {@link Object#equals(Object)} to find objects. Object - * will be added on the main thread - * - * @param object object to remove, may be null or not in the view - */ - public void removeObjectAsync(final T object) { - post(new Runnable() { - @Override - public void run() { - removeObjectSync(object); - } - }); - } - - /** - * Remove all objects from the token list. Objects will be removed on the main thread. - */ - public void clearAsync() { - post(new Runnable() { - @Override - public void run() { - for (T object: getObjects()) { - removeObjectSync(object); - } - } - }); - } - - /** - * Set the count span the current number of hidden objects - */ - private void updateCountSpan() { - //No count span with free form text - if (!preventFreeFormText) { return; } - - Editable text = getText(); - - int visibleCount = getText().getSpans(0, getText().length(), TokenImageSpan.class).length; - countSpan.setCount(getObjects().size() - visibleCount); - - SpannableStringBuilder spannedCountText = new SpannableStringBuilder(countSpan.getCountText()); - spannedCountText.setSpan(countSpan, 0, spannedCountText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - - internalEditInProgress = true; - int countStart = text.getSpanStart(countSpan); - if (countStart != -1) { - //Span is in the text, replace existing text - //This will also remove the span if the count is 0 - text.replace(countStart, text.getSpanEnd(countSpan), spannedCountText); - } else { - text.append(spannedCountText); - } - - internalEditInProgress = false; - } - - /** - * Remove a span from the current EditText and fire the appropriate callback - * - * @param text Editable to remove the span from - * @param span TokenImageSpan to be removed - */ - private void removeSpan(Editable text, TokenImageSpan span) { - //We usually add whitespace after a token, so let's try to remove it as well if it's present - int end = text.getSpanEnd(span); - if (end < text.length() && text.charAt(end) == ' ') { - end += 1; - } - - internalEditInProgress = true; - text.delete(text.getSpanStart(span), end); - internalEditInProgress = false; - - if (allowCollapse && !isFocused()) { - updateCountSpan(); - } - } - - /** - * Insert a new span for an Object - * - * @param tokenSpan span to insert - */ - private void insertSpan(TokenImageSpan tokenSpan) { - CharSequence ssb = tokenizer.wrapTokenValue(tokenToString(tokenSpan.token)); - - Editable editable = getText(); - if (editable == null) return; - - // If we haven't hidden any objects yet, we can try adding it - if (hiddenContent == null) { - internalEditInProgress = true; - int offset = editable.length(); - //There might be a hint visible... - if (hintVisible) { - //...so we need to put the object in in front of the hint - offset = prefix.length(); - } else { - Range currentRange = getCurrentCandidateTokenRange(); - if (currentRange.length() > 0) { - // The user has entered some text that has not yet been tokenized. - // Find the beginning of this text and insert the new token there. - offset = currentRange.start; - } - } - editable.insert(offset, ssb); - editable.insert(offset + ssb.length(), " "); - editable.setSpan(tokenSpan, offset, offset + ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - internalEditInProgress = false; - } else { - CharSequence tokenText = tokenizer.wrapTokenValue(tokenToString(tokenSpan.getToken())); - int start = hiddenContent.length(); - hiddenContent.append(tokenText); - hiddenContent.append(" "); - hiddenContent.setSpan(tokenSpan, start, start + tokenText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - updateCountSpan(); - } - } - - private void updateHint() { - Editable text = getText(); - CharSequence hintText = getHint(); - if (text == null || hintText == null) { - return; - } - - //Show hint if we need to - if (prefix.length() > 0) { - HintSpan[] hints = text.getSpans(0, text.length(), HintSpan.class); - HintSpan hint = null; - int testLength = prefix.length(); - if (hints.length > 0) { - hint = hints[0]; - testLength += text.getSpanEnd(hint) - text.getSpanStart(hint); - } - - if (text.length() == testLength) { - hintVisible = true; - - if (hint != null) { - return;//hint already visible - } - - //We need to display the hint manually - Typeface tf = getTypeface(); - int style = Typeface.NORMAL; - if (tf != null) { - style = tf.getStyle(); - } - ColorStateList colors = getHintTextColors(); - - HintSpan hintSpan = new HintSpan(null, style, (int) getTextSize(), colors, colors); - internalEditInProgress = true; - text.insert(prefix.length(), hintText); - text.setSpan(hintSpan, prefix.length(), prefix.length() + getHint().length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - internalEditInProgress = false; - setSelection(prefix.length()); - } else { - if (hint == null) { - return; //hint already removed - } - - //Remove the hint. There should only ever be one - int sStart = text.getSpanStart(hint); - int sEnd = text.getSpanEnd(hint); - - internalEditInProgress = true; - text.removeSpan(hint); - text.replace(sStart, sEnd, ""); - internalEditInProgress = false; - - hintVisible = false; - } - } - } - - private void clearSelections() { - if (tokenClickStyle == null || !tokenClickStyle.isSelectable()) return; - - Editable text = getText(); - if (text == null) return; - - TokenImageSpan[] tokens = text.getSpans(0, text.length(), TokenImageSpan.class); - boolean shouldRedrawTokens = false; - for (TokenImageSpan token : tokens) { - if (token.view.isSelected()) { - token.view.setSelected(false); - shouldRedrawTokens = true; - } - } - if (shouldRedrawTokens) { - redrawTokens(); - } - } - - protected class TokenImageSpan extends ViewSpan implements NoCopySpan { - private T token; - - @SuppressWarnings("WeakerAccess") - public TokenImageSpan(View d, T token) { - super(d, TokenCompleteTextView.this); - this.token = token; - } - - @SuppressWarnings("WeakerAccess") - public T getToken() { - return this.token; - } - - @SuppressWarnings("WeakerAccess") - public void onClick() { - Editable text = getText(); - if (text == null) return; - - switch (tokenClickStyle) { - case Select: - case SelectDeselect: - - if (!view.isSelected()) { - clearSelections(); - view.setSelected(true); - redrawTokens(); - break; - } - - if (tokenClickStyle == TokenClickStyle.SelectDeselect || !isTokenRemovable(token)) { - view.setSelected(false); - redrawTokens(); - break; - } - //If the view is already selected, we want to delete it - case Delete: - if (isTokenRemovable(token)) { - removeSpan(text, this); - } - break; - case None: - default: - if (getSelectionStart() != text.getSpanEnd(this)) { - //Make sure the selection is not in the middle of the span - setSelection(text.getSpanEnd(this)); - } - } - } - } - - public interface TokenListener { - void onTokenAdded(T token); - void onTokenRemoved(T token); - void onTokenIgnored(T token); - } - - private class TokenSpanWatcher implements SpanWatcher { - - @SuppressWarnings("unchecked cast") - @Override - public void onSpanAdded(Spannable text, Object what, int start, int end) { - if (what instanceof TokenCompleteTextView.TokenImageSpan && !savingState) { - TokenImageSpan token = (TokenImageSpan) what; - - // If we're not focused: collapse the view if necessary - if (!isFocused() && allowCollapse) performCollapse(false); - - if (listener != null) - listener.onTokenAdded(token.getToken()); - } - } - - @SuppressWarnings("unchecked cast") - @Override - public void onSpanRemoved(Spannable text, Object what, int start, int end) { - if (what instanceof TokenCompleteTextView.TokenImageSpan && !savingState) { - TokenImageSpan token = (TokenImageSpan) what; - - if (listener != null) - listener.onTokenRemoved(token.getToken()); - } - } - - @Override - public void onSpanChanged(Spannable text, Object what, - int oldStart, int oldEnd, int newStart, int newEnd) { - } - } - - private class TokenTextWatcher implements TextWatcher { - ArrayList spansToRemove = new ArrayList<>(); - - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - // count > 0 means something will be deleted - if (count > 0 && getText() != null) { - Editable text = getText(); - - int end = start + count; - - TokenImageSpan[] spans = text.getSpans(start, end, TokenImageSpan.class); - - //NOTE: I'm not completely sure this won't cause problems if we get stuck in a text changed loop - //but it appears to work fine. Spans will stop getting removed if this breaks. - ArrayList spansToRemove = new ArrayList<>(); - for (TokenImageSpan token : spans) { - if (text.getSpanStart(token) < end && start < text.getSpanEnd(token)) { - spansToRemove.add(token); - } - } - this.spansToRemove = spansToRemove; - } - } - - @Override - public void afterTextChanged(Editable text) { - ArrayList spansCopy = new ArrayList<>(spansToRemove); - spansToRemove.clear(); - for (TokenImageSpan token : spansCopy) { - //Only remove it if it's still present - if (text.getSpanStart(token) != -1 && text.getSpanEnd(token) != -1) { - removeSpan(text, token); - } - - } - - clearSelections(); - updateHint(); - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - } - - protected List getSerializableObjects() { - List serializables = new ArrayList<>(); - for (Object obj : getObjects()) { - if (obj instanceof Serializable) { - serializables.add((Serializable) obj); - } else { - Log.e(TAG, "Unable to save '" + obj + "'"); - } - } - if (serializables.size() != getObjects().size()) { - String message = "You should make your objects Serializable or Parcelable or\n" + - "override getSerializableObjects and convertSerializableArrayToObjectArray"; - Log.e(TAG, message); - } - - return serializables; - } - - @SuppressWarnings("unchecked") - protected List convertSerializableObjectsToTypedObjects(List s) { - return (List) s; - } - - //Used to determine if we can use the Parcelable interface - private Class reifyParameterizedTypeClass() { - //Borrowed from http://codyaray.com/2013/01/finding-generic-type-parameters-with-guava - - //Figure out what class of objects we have - Class viewClass = getClass(); - while (!viewClass.getSuperclass().equals(TokenCompleteTextView.class)) { - viewClass = viewClass.getSuperclass(); - } - - // This operation is safe. Because viewClass is a direct sub-class, getGenericSuperclass() will - // always return the Type of this class. Because this class is parameterized, the cast is safe - ParameterizedType superclass = (ParameterizedType) viewClass.getGenericSuperclass(); - Type type = superclass.getActualTypeArguments()[0]; - return (Class)type; - } - - @Override - public Parcelable onSaveInstanceState() { - //We don't want to save the listeners as part of the parent - //onSaveInstanceState, so remove them first - removeListeners(); - - //Apparently, saving the parent state on 2.3 mutates the spannable - //prevent this mutation from triggering add or removes of token objects ~mgod - savingState = true; - Parcelable superState = super.onSaveInstanceState(); - savingState = false; - SavedState state = new SavedState(superState); - - state.prefix = prefix; - state.allowCollapse = allowCollapse; - state.performBestGuess = performBestGuess; - state.preventFreeFormText = preventFreeFormText; - state.tokenClickStyle = tokenClickStyle; - Class parameterizedClass = reifyParameterizedTypeClass(); - //Our core array is Parcelable, so use that interface - if (Parcelable.class.isAssignableFrom(parameterizedClass)) { - state.parcelableClassName = parameterizedClass.getName(); - state.baseObjects = getObjects(); - } else { - //Fallback on Serializable - state.parcelableClassName = SavedState.SERIALIZABLE_PLACEHOLDER; - state.baseObjects = getSerializableObjects(); - } - state.tokenizer = tokenizer; - - //So, when the screen is locked or some other system event pauses execution, - //onSaveInstanceState gets called, but it won't restore state later because the - //activity is still in memory, so make sure we add the listeners again - //They should not be restored in onInstanceState if the app is actually killed - //as we removed them before the parent saved instance state, so our adding them in - //onRestoreInstanceState is good. - addListeners(); - - return state; - } - - @SuppressWarnings("unchecked") - @Override - public void onRestoreInstanceState(Parcelable state) { - if (!(state instanceof SavedState)) { - super.onRestoreInstanceState(state); - return; - } - - SavedState ss = (SavedState) state; - super.onRestoreInstanceState(ss.getSuperState()); - - internalEditInProgress = true; - setText(ss.prefix); - prefix = ss.prefix; - internalEditInProgress = false; - updateHint(); - allowCollapse = ss.allowCollapse; - performBestGuess = ss.performBestGuess; - preventFreeFormText = ss.preventFreeFormText; - tokenClickStyle = ss.tokenClickStyle; - tokenizer = ss.tokenizer; - addListeners(); - - List objects; - if (SavedState.SERIALIZABLE_PLACEHOLDER.equals(ss.parcelableClassName)) { - objects = convertSerializableObjectsToTypedObjects(ss.baseObjects); - } else { - objects = (List)ss.baseObjects; - } - - //TODO: change this to keep object spans in the correct locations based on ranges. - for (T obj: objects) { - addObjectSync(obj); - } - - // Collapse the view if necessary - if (!isFocused() && allowCollapse) { - post(new Runnable() { - @Override - public void run() { - //Resize the view and display the +x if appropriate - performCollapse(isFocused()); - } - }); - } - } - - /** - * Handle saving the token state - */ - private static class SavedState extends BaseSavedState { - static final String SERIALIZABLE_PLACEHOLDER = "Serializable"; - - CharSequence prefix; - boolean allowCollapse; - boolean performBestGuess; - boolean preventFreeFormText; - TokenClickStyle tokenClickStyle; - String parcelableClassName; - List baseObjects; - String tokenizerClassName; - Tokenizer tokenizer; - - @SuppressWarnings("unchecked") - SavedState(Parcel in) { - super(in); - prefix = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in); - allowCollapse = in.readInt() != 0; - performBestGuess = in.readInt() != 0; - preventFreeFormText = in.readInt() != 0; - tokenClickStyle = TokenClickStyle.values()[in.readInt()]; - parcelableClassName = in.readString(); - if (SERIALIZABLE_PLACEHOLDER.equals(parcelableClassName)) { - baseObjects = (ArrayList)in.readSerializable(); - } else { - try { - ClassLoader loader = Class.forName(parcelableClassName).getClassLoader(); - baseObjects = in.readArrayList(loader); - } catch (ClassNotFoundException ex) { - //This should really never happen, class had to be available to get here - throw new RuntimeException(ex); - } - } - tokenizerClassName = in.readString(); - try { - ClassLoader loader = Class.forName(tokenizerClassName).getClassLoader(); - tokenizer = in.readParcelable(loader); - } catch (ClassNotFoundException ex) { - //This should really never happen, class had to be available to get here - throw new RuntimeException(ex); - } - } - - SavedState(Parcelable superState) { - super(superState); - } - - @Override - public void writeToParcel(@NonNull Parcel out, int flags) { - super.writeToParcel(out, flags); - TextUtils.writeToParcel(prefix, out, 0); - out.writeInt(allowCollapse ? 1 : 0); - out.writeInt(performBestGuess ? 1 : 0); - out.writeInt(preventFreeFormText ? 1 : 0); - out.writeInt(tokenClickStyle.ordinal()); - if (SERIALIZABLE_PLACEHOLDER.equals(parcelableClassName)) { - out.writeString(SERIALIZABLE_PLACEHOLDER); - out.writeSerializable((Serializable)baseObjects); - } else { - out.writeString(parcelableClassName); - out.writeList(baseObjects); - } - out.writeString(tokenizer.getClass().getCanonicalName()); - out.writeParcelable(tokenizer, 0); - } - - @Override - public String toString() { - String str = "TokenCompleteTextView.SavedState{" - + Integer.toHexString(System.identityHashCode(this)) - + " tokens=" + baseObjects; - return str + "}"; - } - - @SuppressWarnings("hiding") - public static final Parcelable.Creator CREATOR - = new Parcelable.Creator() { - public SavedState createFromParcel(Parcel in) { - return new SavedState(in); - } - - public SavedState[] newArray(int size) { - return new SavedState[size]; - } - }; - } - - /** - * Checks if selection can be deleted. This method is called from TokenInputConnection . - * @param beforeLength the number of characters before the current selection end to check - * @return true if there are no non-deletable pieces of the section - */ - @SuppressWarnings("BooleanMethodIsAlwaysInverted") - public boolean canDeleteSelection(int beforeLength) { - if (getObjects().size() < 1) return true; - - // if beforeLength is 1, we either have no selection or the call is coming from OnKey Event. - // In these scenarios, getSelectionStart() will return the correct value. - - int endSelection = getSelectionEnd(); - int startSelection = beforeLength == 1 ? getSelectionStart() : endSelection - beforeLength; - - Editable text = getText(); - TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class); - - // Iterate over all tokens and allow the deletion - // if there are no tokens not removable in the selection - for (TokenImageSpan span : spans) { - int startTokenSelection = text.getSpanStart(span); - int endTokenSelection = text.getSpanEnd(span); - - // moving on, no need to check this token - if (isTokenRemovable(span.token)) continue; - - if (startSelection == endSelection) { - // Delete single - if (endTokenSelection + 1 == endSelection) { - return false; - } - } else { - // Delete range - // Don't delete if a non removable token is in range - if (startSelection <= startTokenSelection - && endTokenSelection + 1 <= endSelection) { - return false; - } - } - } - return true; - } - - private class TokenInputConnection extends InputConnectionWrapper { - - TokenInputConnection(InputConnection target, boolean mutable) { - super(target, mutable); - } - - // This will fire if the soft keyboard delete key is pressed. - // The onKeyPressed method does not always do this. - @Override - public boolean deleteSurroundingText(int beforeLength, int afterLength) { - // Shouldn't be able to delete any text with tokens that are not removable - if (!canDeleteSelection(beforeLength)) return false; - - //Shouldn't be able to delete prefix, so don't do anything - if (getSelectionStart() <= prefix.length()) { - beforeLength = 0; - return deleteSelectedObject() || super.deleteSurroundingText(beforeLength, afterLength); - } - - return super.deleteSurroundingText(beforeLength, afterLength); - } - - @Override - public boolean setComposingRegion(int start, int end) { - //The hint is displayed inline as regular text, but we want to disable normal compose - //functionality on it, so if we attempt to set a composing region on the hint, set the - //composing region to have length of 0, which indicates there is no composing region - //Without this, on many software keyboards, the first word of the hint will be underlined - if (hintVisible) { - start = end = 0; - } - return super.setComposingRegion(start, end); - } - - @Override - public boolean setComposingText(CharSequence text, int newCursorPosition) { - //There's an issue with some keyboards where they will try to insert the first word - //of the prefix as the composing text - CharSequence hint = getHint(); - if (hint != null && text != null) { - String firstWord = hint.toString().trim().split(" ")[0]; - if (firstWord.length() > 0 && firstWord.equals(text.toString())) { - text = ""; //It was trying to use th hint, so clear that text - } - } - - //Also, some keyboards don't correctly respect the replacement if the replacement - //is the same number of characters as the replacement span - //We need to ignore this value if it's available - if (lastCompletionText != null && text != null && - text.length() == lastCompletionText.length() + 1 && - text.toString().startsWith(lastCompletionText)) { - text = text.subSequence(text.length() - 1, text.length()); - lastCompletionText = null; - } - - return super.setComposingText(text, newCursorPosition); - } - } -} diff --git a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt new file mode 100644 index 00000000..89b01259 --- /dev/null +++ b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt @@ -0,0 +1,1523 @@ +package com.tokenautocomplete + +import android.content.Context +import android.graphics.Rect +import android.graphics.Typeface +import android.os.Parcel +import android.os.Parcelable +import android.text.Editable +import android.text.InputFilter +import android.text.InputType +import android.text.Layout +import android.text.NoCopySpan +import android.text.Selection +import android.text.SpanWatcher +import android.text.Spannable +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.TextUtils +import android.text.TextWatcher +import android.text.style.ForegroundColorSpan +import android.util.AttributeSet +import android.util.Log +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.accessibility.AccessibilityEvent +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.ExtractedText +import android.view.inputmethod.ExtractedTextRequest +import android.view.inputmethod.InputConnection +import android.view.inputmethod.InputConnectionWrapper +import android.view.inputmethod.InputMethodManager +import android.widget.ListView +import android.widget.TextView +import android.widget.TextView.OnEditorActionListener +import androidx.annotation.UiThread +import androidx.appcompat.widget.AppCompatAutoCompleteTextView +import java.io.Serializable +import java.lang.reflect.ParameterizedType +import java.util.* + +/** + * GMail style auto complete view with easy token customization + * override getViewForObject to provide your token view + *

+ * Created by mgod on 9/12/13. + * + * @author mgod + */ +abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, OnEditorActionListener, + ViewSpan.Layout { + //When the user clicks on a token... + enum class TokenClickStyle(val isSelectable: Boolean) { + None(false), //...do nothing, but make sure the cursor is not in the token + Delete(false), //...delete the token + Select(true), //...select the token. A second click will delete it. + SelectDeselect(true); + + } + + private var tokenizer: Tokenizer? = null + private var selectedObject: T? = null + private var listener: TokenListener? = null + private var spanWatcher: TokenSpanWatcher = TokenSpanWatcher() + private var textWatcher: TokenTextWatcher = TokenTextWatcher() + private var countSpan: CountSpan = CountSpan() + private var hiddenContent: SpannableStringBuilder? = null + private var tokenClickStyle: TokenClickStyle? = TokenClickStyle.None + private var prefix: CharSequence? = "" + private var hintVisible = false + private var lastLayout: Layout? = null + private var initialized = false + private var performBestGuess = true + private var preventFreeFormText = true + private var savingState = false + private var shouldFocusNext = false + private var allowCollapse = true + private var internalEditInProgress = false + private var tokenLimit = -1 + + @Transient + private var lastCompletionText: String? = null + + /** + * Add the TextChangedListeners + */ + protected fun addListeners() { + val text = text + if (text != null) { + text.setSpan(spanWatcher, 0, text.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + addTextChangedListener(textWatcher) + } + } + + /** + * Remove the TextChangedListeners + */ + protected fun removeListeners() { + val text = text + if (text != null) { + val spanWatchers = text.getSpans(0, text.length, TokenSpanWatcher::class.java) + for (watcher in spanWatchers) { + text.removeSpan(watcher) + } + removeTextChangedListener(textWatcher) + } + } + + /** + * Initialise the variables and various listeners + */ + private fun init() { + if (initialized) return + + // Initialise variables + setTokenizer(CharacterTokenizer(listOf(',', ';'), ",")) + + // Initialise TextChangedListeners + addListeners() + setTextIsSelectable(false) + isLongClickable = false + + //In theory, get the soft keyboard to not supply suggestions. very unreliable + inputType = inputType or + InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or + InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE + setHorizontallyScrolling(false) + + // Listen to IME action keys + setOnEditorActionListener(this) + + // Initialise the text filter (listens for the split chars) + filters = + arrayOf(InputFilter { source, _, _, _, destinationStart, destinationEnd -> + if (internalEditInProgress) { + return@InputFilter null + } + + // Token limit check + if (tokenLimit != -1 && objects.size == tokenLimit) { + return@InputFilter "" + } + + //Detect split characters, remove them and complete the current token instead + if (tokenizer!!.containsTokenTerminator(source)) { + //Only perform completion if we don't allow free form text, or if there's enough + //content to believe this should be a token + if (preventFreeFormText || currentCompletionText().isNotEmpty()) { + performCompletion() + return@InputFilter "" + } + } + + //We need to not do anything when we would delete the prefix + if (destinationStart < prefix!!.length) { + //when setText is called, which should only be called during restoring, + //destinationStart and destinationEnd are 0. If not checked, it will clear out + //the prefix. + //This is why we need to return null in this if condition to preserve state. + if (destinationStart == 0 && destinationEnd == 0) { + return@InputFilter null + } else return@InputFilter if (destinationEnd <= prefix!!.length) { + //Don't do anything + prefix!!.subSequence(destinationStart, destinationEnd) + } else { + //Delete everything up to the prefix + prefix!!.subSequence(destinationStart, prefix!!.length) + } + } + null + }) + initialized = true + } + + constructor(context: Context?) : super(context) { + init() + } + + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) { + init() + } + + constructor(context: Context?, attrs: AttributeSet?, defStyle: Int) : super( + context, + attrs, + defStyle + ) { + init() + } + + override fun performFiltering(text: CharSequence, keyCode: Int) { + val filter = filter + filter?.filter(currentCompletionText(), this) + } + + fun setTokenizer(t: Tokenizer?) { + tokenizer = t + } + + /** + * Set the action to be taken when a Token is clicked + * + * @param cStyle The TokenClickStyle + */ + fun setTokenClickStyle(cStyle: TokenClickStyle?) { + tokenClickStyle = cStyle + } + + /** + * Set the listener that will be notified of changes in the Token list + * + * @param l The TokenListener + */ + fun setTokenListener(l: TokenListener?) { + listener = l + } + + /** + * Override if you want to prevent a token from being added. Defaults to false. + * @param token the token to check + * @return true if the token should not be added, false if it's ok to add it. + */ + open fun shouldIgnoreToken(token: T): Boolean { + return false + } + + /** + * Override if you want to prevent a token from being removed. Defaults to true. + * @param token the token to check + * @return false if the token should not be removed, true if it's ok to remove it. + */ + fun isTokenRemovable(@Suppress("unused_parameter") token: T): Boolean { + return true + } + + /** + * A String of text that is shown before all the tokens inside the EditText + * (Think "To: " in an email address field. I would advise against this: use a label and a hint. + * + * @param p String with the hint + */ + @Suppress("MemberVisibilityCanBePrivate") + fun setPrefix(p: CharSequence?) { + //Have to clear and set the actual text before saving the prefix to avoid the prefix filter + val prevPrefix = prefix + prefix = p + val text = text + if (text != null) { + internalEditInProgress = true + if (prevPrefix != null) { + text.replace(0, prevPrefix.length, p) + } else { + text.insert(0, p) + } + internalEditInProgress = false + } + //prefix = p; + updateHint() + } + + /** + * + * You can get a color integer either using + * [androidx.core.content.ContextCompat.getColor] + * or with [android.graphics.Color.parseColor]. + * + * [android.graphics.Color.parseColor] + * accepts these formats (copied from android.graphics.Color): + * You can use: '#RRGGBB', '#AARRGGBB' + * or one of the following names: 'red', 'blue', 'green', 'black', 'white', + * 'gray', 'cyan', 'magenta', 'yellow', 'lightgray', 'darkgray', 'grey', + * 'lightgrey', 'darkgrey', 'aqua', 'fuchsia', 'lime', 'maroon', 'navy', + * 'olive', 'purple', 'silver', 'teal'. + * + * @param prefix prefix + * @param color A single color value in the form 0xAARRGGBB. + */ + fun setPrefix(prefix: CharSequence?, color: Int) { + val spannablePrefix = SpannableString(prefix) + spannablePrefix.setSpan(ForegroundColorSpan(color), 0, spannablePrefix.length, 0) + setPrefix(spannablePrefix) + } + + /** + * Get the list of Tokens + * + * @return List of tokens + */ + val objects: List + get() { + val objects = ArrayList() + var text = text + if (hiddenContent != null) { + text = hiddenContent + } + for (span in text.getSpans(0, text.length, TokenImageSpan::class.java)) { + @Suppress("unchecked_cast") + objects.add(span.token as T) + } + return objects + } + + /** + * Get the content entered in the text field, including hidden text when ellipsized + * + * @return CharSequence of the entered content + */ + val contentText: CharSequence + get() = hiddenContent ?: text + + /** + * Set whether we try to guess an entry from the autocomplete spinner or just use the + * defaultObject implementation for inline token completion. + * + * @param guess true to enable guessing + */ + fun performBestGuess(guess: Boolean) { + performBestGuess = guess + } + + /** + * If set to true, the only content in this view will be the tokens and the current completion + * text. Use this setting to create things like lists of email addresses. If false, it the view + * will allow text in addition to tokens. Use this if you want to use the token search to find + * things like user names or hash tags to put in with text. + * + * @param prevent true to prevent non-token text. Defaults to true. + */ + fun preventFreeFormText(prevent: Boolean) { + preventFreeFormText = prevent + } + + /** + * Set whether the view should collapse to a single line when it loses focus. + * + * @param allowCollapse true if it should collapse + */ + fun allowCollapse(allowCollapse: Boolean) { + this.allowCollapse = allowCollapse + } + + /** + * Set a number of tokens limit. + * + * @param tokenLimit The number of tokens permitted. -1 value disables limit. + */ + @Suppress("unused") + fun setTokenLimit(tokenLimit: Int) { + this.tokenLimit = tokenLimit + } + + /** + * A token view for the object + * + * @param object the object selected by the user from the list + * @return a view to display a token in the text field for the object + */ + protected abstract fun getViewForObject(`object`: T): View + + /** + * Provides a default completion when the user hits , and there is no item in the completion + * list + * + * @param completionText the current text we are completing against + * @return a best guess for what the user meant to complete or null if you don't want a guess + */ + protected abstract fun defaultObject(completionText: String?): T//See if this is where we should start the selection + + //Replace token spans +//Need to take the existing tet buffer and + // - replace all tokens with a decent string representation of the object + // - set the selection span to the corresponding location in the new CharSequence + /** + * Correctly build accessibility string for token contents + * + * This seems to be a hidden API, but there doesn't seem to be another reasonable way + * @return custom string for accessibility + */ + @Suppress("MemberVisibilityCanBePrivate") + val textForAccessibility: CharSequence + get() { + if (objects.isEmpty()) { + return text + } + var description = SpannableStringBuilder() + val text = text + var selectionStart = -1 + var selectionEnd = -1 + var i: Int + //Need to take the existing tet buffer and + // - replace all tokens with a decent string representation of the object + // - set the selection span to the corresponding location in the new CharSequence + i = 0 + while (i < text.length) { + + //See if this is where we should start the selection + val origSelectionStart = Selection.getSelectionStart(text) + if (i == origSelectionStart) { + selectionStart = description.length + } + val origSelectionEnd = Selection.getSelectionEnd(text) + if (i == origSelectionEnd) { + selectionEnd = description.length + } + + //Replace token spans + val tokens = text.getSpans(i, i, TokenImageSpan::class.java) + if (tokens.isNotEmpty()) { + val token = tokens[0] + description = + description.append(tokenizer!!.wrapTokenValue(token.token.toString())) + i = text.getSpanEnd(token) + ++i + continue + } + description = description.append(text.subSequence(i, i + 1)) + ++i + } + val origSelectionStart = Selection.getSelectionStart(text) + if (i == origSelectionStart) { + selectionStart = description.length + } + val origSelectionEnd = Selection.getSelectionEnd(text) + if (i == origSelectionEnd) { + selectionEnd = description.length + } + if (selectionStart >= 0 && selectionEnd >= 0) { + Selection.setSelection(description, selectionStart, selectionEnd) + } + return description + } + + /** + * Clear the completion text only. + */ + @Suppress("unused") + fun clearCompletionText() { + //Respect currentCompletionText in case hint is visible or if other checks are added. + if (currentCompletionText().isEmpty()) { + return + } + val currentRange = currentCandidateTokenRange + internalEditInProgress = true + text.delete(currentRange.start, currentRange.end) + internalEditInProgress = false + } + + override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) { + super.onInitializeAccessibilityEvent(event) + if (event.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) { + val text = textForAccessibility + event.fromIndex = Selection.getSelectionStart(text) + event.toIndex = Selection.getSelectionEnd(text) + event.itemCount = text.length + } + } + //Don't try to search the hint for possible tokenizable strings + + //We want to find the largest string that contains the selection end that is not already tokenized + private val currentCandidateTokenRange: Range + get() { + val editable = text + val cursorEndPosition = selectionEnd + var candidateStringStart = prefix!!.length + var candidateStringEnd = editable.length + if (hintVisible) { + //Don't try to search the hint for possible tokenizable strings + candidateStringEnd = candidateStringStart + } + + //We want to find the largest string that contains the selection end that is not already tokenized + val spans = editable.getSpans(prefix!!.length, editable.length, TokenImageSpan::class.java) + for (span in spans) { + val spanEnd = editable.getSpanEnd(span) + if (spanEnd in (candidateStringStart + 1)..cursorEndPosition) { + candidateStringStart = spanEnd + } + val spanStart = editable.getSpanStart(span) + if (candidateStringEnd > spanStart && cursorEndPosition <= spanEnd) { + candidateStringEnd = spanStart + } + } + val tokenRanges = + tokenizer!!.findTokenRanges(editable, candidateStringStart, candidateStringEnd) + for (range in tokenRanges) { + @Suppress("unused") + if (range.start <= cursorEndPosition && cursorEndPosition <= range.end) { + return range + } + } + return Range(cursorEndPosition, cursorEndPosition) + } + + /** + * Override if you need custom logic to provide a sting representation of a token + * @param token the token to convert + * @return the string representation of the token. Defaults to [Object.toString] + */ + @Suppress("MemberVisibilityCanBePrivate") + protected fun tokenToString(token: T): CharSequence { + return token.toString() + } + + protected fun currentCompletionText(): String { + if (hintVisible) return "" //Can't have any text if the hint is visible + val editable = text + val currentRange = currentCandidateTokenRange + val result = TextUtils.substring(editable, currentRange.start, currentRange.end) + Log.d(TAG, "Current completion text: $result") + return result + } + + @Suppress("MemberVisibilityCanBePrivate") + protected fun maxTextWidth(): Float { + return (width - paddingLeft - paddingRight).toFloat() + } + + override val maxViewSpanWidth: Int + get() = maxTextWidth().toInt() + + fun redrawTokens() { + // There's no straight-forward way to convince the widget to redraw the text and spans. We trigger a redraw by + // making an invisible change (either adding or removing a dummy span). + val text = text ?: return + val textLength = text.length + val dummySpans = text.getSpans(0, textLength, DummySpan::class.java) + if (dummySpans.isNotEmpty()) { + text.removeSpan(DummySpan.INSTANCE) + } else { + text.setSpan( + DummySpan.INSTANCE, + 0, + textLength, + Spannable.SPAN_INCLUSIVE_INCLUSIVE + ) + } + } + + override fun enoughToFilter(): Boolean { + if (tokenizer == null || hintVisible) { + return false + } + val cursorPosition = selectionEnd + if (cursorPosition < 0) { + return false + } + val currentCandidateRange = currentCandidateTokenRange + + //Don't allow 0 length entries to filter + @Suppress("MemberVisibilityCanBePrivate") + return currentCandidateRange.length() >= threshold.coerceAtLeast(1) + } + + override fun performCompletion() { + if ((adapter == null || listSelection == ListView.INVALID_POSITION) && enoughToFilter()) { + val bestGuess: Any = if (adapter != null && adapter.count > 0 && performBestGuess) { + adapter.getItem(0) + } else { + defaultObject(currentCompletionText()) + } + replaceText(convertSelectionToString(bestGuess)) + } else { + super.performCompletion() + } + } + + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection { + val superConn = super.onCreateInputConnection(outAttrs) + val conn = TokenInputConnection(superConn, true) + outAttrs.imeOptions = outAttrs.imeOptions and EditorInfo.IME_FLAG_NO_ENTER_ACTION.inv() + outAttrs.imeOptions = outAttrs.imeOptions or EditorInfo.IME_FLAG_NO_EXTRACT_UI + return conn + } + + /** + * Create a token and hide the keyboard when the user sends the DONE IME action + * Use IME_NEXT if you want to create a token and go to the next field + */ + private fun handleDone() { + // Attempt to complete the current token token + performCompletion() + + // Hide the keyboard + val imm = context.getSystemService( + Context.INPUT_METHOD_SERVICE + ) as InputMethodManager + imm.hideSoftInputFromWindow(windowToken, 0) + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + val handled = super.onKeyUp(keyCode, event) + if (shouldFocusNext) { + shouldFocusNext = false + handleDone() + } + return handled + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + var handled = false + when (keyCode) { + KeyEvent.KEYCODE_TAB, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_DPAD_CENTER -> if (event.hasNoModifiers()) { + shouldFocusNext = true + handled = true + } + KeyEvent.KEYCODE_DEL -> handled = !canDeleteSelection(1) || deleteSelectedObject() + } + return handled || super.onKeyDown(keyCode, event) + } + + private fun deleteSelectedObject(): Boolean { + if (tokenClickStyle != null && tokenClickStyle!!.isSelectable) { + val text = text ?: return false + @Suppress("unchecked_cast") + val spans: Array = + text.getSpans(0, text.length, TokenImageSpan::class.java) as Array + for (span in spans) { + if (span.view.isSelected) { + removeSpan(text, span) + return true + } + } + } + return false + } + + override fun onEditorAction(view: TextView, action: Int, keyEvent: KeyEvent): Boolean { + if (action == EditorInfo.IME_ACTION_DONE) { + handleDone() + return true + } + return false + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + val action = event.actionMasked + val text = text + var handled = false + if (tokenClickStyle == TokenClickStyle.None) { + handled = super.onTouchEvent(event) + } + if (isFocused && text != null && lastLayout != null && action == MotionEvent.ACTION_UP) { + val offset = getOffsetForPosition(event.x, event.y) + if (offset != -1) { + @Suppress("unchecked_cast") + val links: Array = + text.getSpans(offset, offset, TokenImageSpan::class.java) as Array + if (links.isNotEmpty()) { + links[0].onClick() + handled = true + } else { + //We didn't click on a token, so if any are selected, we should clear that + clearSelections() + } + } + } + if (!handled && tokenClickStyle != TokenClickStyle.None) { + handled = super.onTouchEvent(event) + } + return handled + } + + override fun onSelectionChanged(selStart: Int, selEnd: Int) { + var selectionStart = selStart + if (hintVisible) { + //Don't let users select the hint + selectionStart = 0 + } + //Never let users select text + val selectionEnd = selectionStart + if (tokenClickStyle != null && tokenClickStyle!!.isSelectable) { + val text = text + if (text != null) { + clearSelections() + } + } + if (prefix != null && (selectionStart < prefix!!.length || selectionEnd < prefix!!.length)) { + //Don't let users select the prefix + setSelection(prefix!!.length) + } else { + val text = text + if (text != null) { + //Make sure if we are in a span, we select the spot 1 space after the span end + @Suppress("unchecked_cast") + val spans: Array = + text.getSpans(selectionStart, selectionEnd, TokenImageSpan::class.java) as Array + for (span in spans) { + val spanEnd = text.getSpanEnd(span) + if (selectionStart <= spanEnd && text.getSpanStart(span) < selectionStart) { + if (spanEnd == text.length) setSelection(spanEnd) else setSelection(spanEnd + 1) + return + } + } + } + super.onSelectionChanged(selectionStart, selectionEnd) + } + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + lastLayout = layout //Used for checking text positions + } + + /** + * Collapse the view by removing all the tokens not on the first line. Displays a "+x" token. + * Restores the hidden tokens when the view gains focus. + * + * @param hasFocus boolean indicating whether we have the focus or not. + */ + fun performCollapse(hasFocus: Boolean) { + internalEditInProgress = true + if (!hasFocus) { + // Display +x thingy/ellipse if appropriate + val text = text + if (text != null && hiddenContent == null && lastLayout != null) { + + //Ellipsize copies spans, so we need to stop listening to span changes here + text.removeSpan(spanWatcher) + val temp = if (preventFreeFormText) countSpan else null + val ellipsized = SpanUtils.ellipsizeWithSpans( + prefix, temp, objects.size, + lastLayout!!.paint, text, maxTextWidth() + ) + if (ellipsized != null) { + hiddenContent = SpannableStringBuilder(text) + setText(ellipsized) + TextUtils.copySpansFrom( + ellipsized, 0, ellipsized.length, + TokenImageSpan::class.java, getText(), 0 + ) + TextUtils.copySpansFrom( + text, 0, hiddenContent!!.length, + TokenImageSpan::class.java, hiddenContent, 0 + ) + hiddenContent!!.setSpan( + spanWatcher, + 0, + hiddenContent!!.length, + Spanned.SPAN_INCLUSIVE_INCLUSIVE + ) + } else { + getText().setSpan( + spanWatcher, + 0, + getText().length, + Spanned.SPAN_INCLUSIVE_INCLUSIVE + ) + } + } + } else { + if (hiddenContent != null) { + text = hiddenContent + TextUtils.copySpansFrom( + hiddenContent, 0, hiddenContent!!.length, + TokenImageSpan::class.java, text, 0 + ) + hiddenContent = null + if (hintVisible) { + setSelection(prefix!!.length) + } else { + post { setSelection(text.length) } + } + @Suppress("unchecked_cast") + val watchers: Array = + text.getSpans(0, text.length, TokenSpanWatcher::class.java) as Array + if (watchers.isEmpty()) { + //Span watchers can get removed in setText + text.setSpan(spanWatcher, 0, text.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + } + } + internalEditInProgress = false + } + + public override fun onFocusChanged(hasFocus: Boolean, direction: Int, previous: Rect?) { + super.onFocusChanged(hasFocus, direction, previous) + + // Clear sections when focus changes to avoid a token remaining selected + clearSelections() + + // Collapse the view to a single line + if (allowCollapse) performCollapse(hasFocus) + } + + override fun convertSelectionToString(selectedObject: Any): CharSequence { + @Suppress("unchecked_cast") + this.selectedObject = selectedObject as T + return "" + } + + @Suppress("MemberVisibilityCanBePrivate") + protected fun buildSpanForObject(obj: T?): TokenImageSpan? { + if (obj == null) { + return null + } + val tokenView = getViewForObject(obj) + return TokenImageSpan(tokenView, obj) + } + + override fun replaceText(ignore: CharSequence) { + clearComposingText() + + // Don't build a token for an empty String + if (selectedObject == null || selectedObject.toString() == "") return + val tokenSpan = buildSpanForObject(selectedObject) + val editable = text + val candidateRange = currentCandidateTokenRange + val original = TextUtils.substring(editable, candidateRange.start, candidateRange.end) + + //Keep track of replacements for a bug workaround + if (original.isNotEmpty()) { + lastCompletionText = original + } + if (editable != null) { + internalEditInProgress = true + if (tokenSpan == null) { + editable.replace(candidateRange.start, candidateRange.end, "") + } else if (shouldIgnoreToken(tokenSpan.token)) { + editable.replace(candidateRange.start, candidateRange.end, "") + if (listener != null) { + listener!!.onTokenIgnored(tokenSpan.token) + } + } else { + val ssb = SpannableStringBuilder(tokenizer!!.wrapTokenValue(tokenToString(tokenSpan.token))) + editable.replace(candidateRange.start, candidateRange.end, ssb) + editable.setSpan( + tokenSpan, + candidateRange.start, + candidateRange.start + ssb.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + editable.insert(candidateRange.start + ssb.length, " ") + } + internalEditInProgress = false + } + } + + override fun extractText(request: ExtractedTextRequest, outText: ExtractedText): Boolean { + return try { + super.extractText(request, outText) + } catch (ex: IndexOutOfBoundsException) { + Log.d(TAG, "extractText hit IndexOutOfBoundsException. This may be normal.", ex) + false + } + } + + /** + * Append a token object to the object list. May only be called from the main thread. + * + * @param object the object to add to the displayed tokens + */ + @UiThread + fun addObjectSync(`object`: T?) { + if (`object` == null) return + if (shouldIgnoreToken(`object`)) { + if (listener != null) { + listener!!.onTokenIgnored(`object`) + } + return + } + if (tokenLimit != -1 && objects.size == tokenLimit) return + insertSpan(buildSpanForObject(`object`)) + if (text != null && isFocused) setSelection(text.length) + } + + /** + * Append a token object to the object list. Object will be added on the main thread. + * + * @param object the object to add to the displayed tokens + */ + fun addObjectAsync(`object`: T) { + post { addObjectSync(`object`) } + } + + /** + * Remove an object from the token list. Will remove duplicates if present or do nothing if no + * object is present in the view. Uses [Object.equals] to find objects. May only + * be called from the main thread + * + * @param object object to remove, may be null or not in the view + */ + @UiThread + fun removeObjectSync(`object`: T) { + //To make sure all the appropriate callbacks happen, we just want to piggyback on the + //existing code that handles deleting spans when the text changes + val texts = ArrayList() + //If there is hidden content, it's important that we update it first + if (hiddenContent != null) { + texts.add(hiddenContent!!) + } + if (text != null) { + texts.add(text) + } + + // If the object is currently visible, remove it + for (text in texts) { + @Suppress("unchecked_cast") + val spans: Array = + text.getSpans(0, text.length, TokenImageSpan::class.java) as Array + for (span in spans) { + if (span.token == `object`) { + removeSpan(text, span) + } + } + } + updateCountSpan() + } + + /** + * Remove an object from the token list. Will remove duplicates if present or do nothing if no + * object is present in the view. Uses [Object.equals] to find objects. Object + * will be added on the main thread + * + * @param object object to remove, may be null or not in the view + */ + fun removeObjectAsync(`object`: T) { + post { removeObjectSync(`object`) } + } + + /** + * Remove all objects from the token list. Objects will be removed on the main thread. + */ + fun clearAsync() { + post { + for (`object` in objects) { + removeObjectSync(`object`) + } + } + } + + /** + * Set the count span the current number of hidden objects + */ + private fun updateCountSpan() { + //No count span with free form text + if (!preventFreeFormText) { + return + } + val text = text + val visibleCount = getText().getSpans(0, getText().length, TokenImageSpan::class.java).size + countSpan.setCount(objects.size - visibleCount) + val spannedCountText = SpannableStringBuilder(countSpan.countText) + spannedCountText.setSpan( + countSpan, + 0, + spannedCountText.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + internalEditInProgress = true + val countStart = text.getSpanStart(countSpan) + if (countStart != -1) { + //Span is in the text, replace existing text + //This will also remove the span if the count is 0 + text.replace(countStart, text.getSpanEnd(countSpan), spannedCountText) + } else { + text.append(spannedCountText) + } + internalEditInProgress = false + } + + /** + * Remove a span from the current EditText and fire the appropriate callback + * + * @param text Editable to remove the span from + * @param span TokenImageSpan to be removed + */ + private fun removeSpan(text: Editable, span: TokenImageSpan) { + //We usually add whitespace after a token, so let's try to remove it as well if it's present + var end = text.getSpanEnd(span) + if (end < text.length && text[end] == ' ') { + end += 1 + } + internalEditInProgress = true + text.delete(text.getSpanStart(span), end) + internalEditInProgress = false + if (allowCollapse && !isFocused) { + updateCountSpan() + } + } + + /** + * Insert a new span for an Object + * + * @param tokenSpan span to insert + */ + private fun insertSpan(tokenSpan: TokenImageSpan?) { + val ssb = tokenizer!!.wrapTokenValue(tokenToString(tokenSpan!!.token)) + val editable = text ?: return + + // If we haven't hidden any objects yet, we can try adding it + if (hiddenContent == null) { + internalEditInProgress = true + var offset = editable.length + //There might be a hint visible... + if (hintVisible) { + //...so we need to put the object in in front of the hint + offset = prefix!!.length + } else { + val currentRange = currentCandidateTokenRange + if (currentRange.length() > 0) { + // The user has entered some text that has not yet been tokenized. + // Find the beginning of this text and insert the new token there. + offset = currentRange.start + } + } + editable.insert(offset, ssb) + editable.insert(offset + ssb.length, " ") + editable.setSpan( + tokenSpan, + offset, + offset + ssb.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + internalEditInProgress = false + } else { + val tokenText = tokenizer!!.wrapTokenValue( + tokenToString( + tokenSpan.token + ) + ) + val start = hiddenContent!!.length + hiddenContent!!.append(tokenText) + hiddenContent!!.append(" ") + hiddenContent!!.setSpan( + tokenSpan, + start, + start + tokenText.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + updateCountSpan() + } + } + + private fun updateHint() { + val text = text + val hintText = hint + if (text == null || hintText == null) { + return + } + + //Show hint if we need to + if (prefix?.isNotEmpty() == true) { + val hints = text.getSpans(0, text.length, HintSpan::class.java) + var hint: HintSpan? = null + var testLength = prefix!!.length + if (hints.isNotEmpty()) { + hint = hints[0] + testLength += text.getSpanEnd(hint) - text.getSpanStart(hint) + } + if (text.length == testLength) { + hintVisible = true + if (hint != null) { + return //hint already visible + } + + //We need to display the hint manually + val tf = typeface + var style = Typeface.NORMAL + if (tf != null) { + style = tf.style + } + val colors = hintTextColors + val hintSpan = HintSpan(null, style, textSize.toInt(), colors, colors) + internalEditInProgress = true + text.insert(prefix!!.length, hintText) + text.setSpan( + hintSpan, + prefix!!.length, + prefix!!.length + getHint().length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + internalEditInProgress = false + setSelection(prefix!!.length) + } else { + if (hint == null) { + return //hint already removed + } + + //Remove the hint. There should only ever be one + val sStart = text.getSpanStart(hint) + val sEnd = text.getSpanEnd(hint) + internalEditInProgress = true + text.removeSpan(hint) + text.replace(sStart, sEnd, "") + internalEditInProgress = false + hintVisible = false + } + } + } + + private fun clearSelections() { + if (tokenClickStyle == null || !tokenClickStyle!!.isSelectable) return + val text = text ?: return + @Suppress("unchecked_cast") + val tokens: Array = + text.getSpans(0, text.length, TokenImageSpan::class.java) as Array + var shouldRedrawTokens = false + for (token in tokens) { + if (token.view.isSelected) { + token.view.isSelected = false + shouldRedrawTokens = true + } + } + if (shouldRedrawTokens) { + redrawTokens() + } + } + + inner class TokenImageSpan(d: View, val token: T) : ViewSpan(d, this@TokenCompleteTextView), + NoCopySpan { + fun onClick() { + val text = text ?: return + when (tokenClickStyle) { + TokenClickStyle.Select, TokenClickStyle.SelectDeselect -> { + if (!view.isSelected) { + clearSelections() + view.isSelected = true + redrawTokens() + } else if (tokenClickStyle == TokenClickStyle.SelectDeselect || !isTokenRemovable(token)) { + view.isSelected = false + redrawTokens() + } else if (isTokenRemovable(token)) { + removeSpan(text, this) + } + } + TokenClickStyle.Delete -> if (isTokenRemovable(token)) { + removeSpan(text, this) + } + TokenClickStyle.None -> if (selectionStart != text.getSpanEnd(this)) { + //Make sure the selection is not in the middle of the span + setSelection(text.getSpanEnd(this)) + } + else -> if (selectionStart != text.getSpanEnd(this)) { + setSelection(text.getSpanEnd(this)) + } + } + } + } + + interface TokenListener { + fun onTokenAdded(token: T) + fun onTokenRemoved(token: T) + fun onTokenIgnored(token: T) + } + + private inner class TokenSpanWatcher : SpanWatcher { + override fun onSpanAdded(text: Spannable, what: Any, start: Int, end: Int) { + if (what is TokenCompleteTextView<*>.TokenImageSpan && !savingState) { + + // If we're not focused: collapse the view if necessary + if (!isFocused && allowCollapse) performCollapse(false) + @Suppress("unchecked_cast") + if (listener != null) listener!!.onTokenAdded(what.token as T) + } + } + + override fun onSpanRemoved(text: Spannable, what: Any, start: Int, end: Int) { + if (what is TokenCompleteTextView<*>.TokenImageSpan && !savingState) { + @Suppress("unchecked_cast") + if (listener != null) listener!!.onTokenRemoved(what.token as T) + } + } + + override fun onSpanChanged( + text: Spannable, what: Any, + oldStart: Int, oldEnd: Int, newStart: Int, newEnd: Int + ) { + } + } + + private inner class TokenTextWatcher : TextWatcher { + var spansToRemove = ArrayList() + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + // count > 0 means something will be deleted + if (count > 0 && text != null) { + val text = text + val end = start + count + @Suppress("unchecked_cast") + val spans = text.getSpans(start, end, TokenImageSpan::class.java) as Array.TokenImageSpan> + + //NOTE: I'm not completely sure this won't cause problems if we get stuck in a text changed loop + //but it appears to work fine. Spans will stop getting removed if this breaks. + val spansToRemove = ArrayList() + for (token in spans) { + if (text.getSpanStart(token) < end && start < text.getSpanEnd(token)) { + spansToRemove.add(token) + } + } + this.spansToRemove = spansToRemove + } + } + + override fun afterTextChanged(text: Editable) { + val spansCopy = ArrayList(spansToRemove) + spansToRemove.clear() + for (token in spansCopy) { + //Only remove it if it's still present + if (text.getSpanStart(token) != -1 && text.getSpanEnd(token) != -1) { + removeSpan(text, token) + } + } + clearSelections() + updateHint() + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} + } + + @Suppress("MemberVisibilityCanBePrivate") + protected val serializableObjects: List + get() { + val serializables: MutableList = ArrayList() + for (obj in objects) { + if (obj is Serializable) { + serializables.add(obj as Serializable) + } else { + Log.e(TAG, "Unable to save '$obj'") + } + } + if (serializables.size != objects.size) { + val message = """ + You should make your objects Serializable or Parcelable or + override getSerializableObjects and convertSerializableArrayToObjectArray + """.trimIndent() + Log.e(TAG, message) + } + return serializables + } + + @Suppress("MemberVisibilityCanBePrivate") + protected fun convertSerializableObjectsToTypedObjects(s: List<*>?): List? { + @Suppress("unchecked_cast") + return s as List? + } + + //Used to determine if we can use the Parcelable interface + private fun reifyParameterizedTypeClass(): Class<*> { + //Borrowed from http://codyaray.com/2013/01/finding-generic-type-parameters-with-guava + + //Figure out what class of objects we have + var viewClass: Class<*> = javaClass + while (viewClass.superclass != TokenCompleteTextView::class.java) { + viewClass = viewClass.superclass + } + + // This operation is safe. Because viewClass is a direct sub-class, getGenericSuperclass() will + // always return the Type of this class. Because this class is parameterized, the cast is safe + val superclass = viewClass.genericSuperclass as ParameterizedType + val type = superclass.actualTypeArguments[0] + return type as Class<*> + } + + override fun onSaveInstanceState(): Parcelable { + //We don't want to save the listeners as part of the parent + //onSaveInstanceState, so remove them first + removeListeners() + + //Apparently, saving the parent state on 2.3 mutates the spannable + //prevent this mutation from triggering add or removes of token objects ~mgod + savingState = true + val superState = super.onSaveInstanceState() + savingState = false + val state = SavedState(superState) + state.prefix = prefix + state.allowCollapse = allowCollapse + state.performBestGuess = performBestGuess + state.preventFreeFormText = preventFreeFormText + state.tokenClickStyle = tokenClickStyle + val parameterizedClass = reifyParameterizedTypeClass() + //Our core array is Parcelable, so use that interface + if (Parcelable::class.java.isAssignableFrom(parameterizedClass)) { + state.parcelableClassName = parameterizedClass.name + state.baseObjects = objects + } else { + //Fallback on Serializable + state.parcelableClassName = SavedState.SERIALIZABLE_PLACEHOLDER + state.baseObjects = serializableObjects + } + state.tokenizer = tokenizer + + //So, when the screen is locked or some other system event pauses execution, + //onSaveInstanceState gets called, but it won't restore state later because the + //activity is still in memory, so make sure we add the listeners again + //They should not be restored in onInstanceState if the app is actually killed + //as we removed them before the parent saved instance state, so our adding them in + //onRestoreInstanceState is good. + addListeners() + return state + } + + override fun onRestoreInstanceState(state: Parcelable) { + if (state !is SavedState) { + super.onRestoreInstanceState(state) + return + } + super.onRestoreInstanceState(state.superState) + internalEditInProgress = true + setText(state.prefix) + prefix = state.prefix + internalEditInProgress = false + updateHint() + allowCollapse = state.allowCollapse + performBestGuess = state.performBestGuess + preventFreeFormText = state.preventFreeFormText + tokenClickStyle = state.tokenClickStyle + tokenizer = state.tokenizer + addListeners() + val objects: List? = if (SavedState.SERIALIZABLE_PLACEHOLDER == state.parcelableClassName) { + convertSerializableObjectsToTypedObjects(state.baseObjects) + } else { + @Suppress("unchecked_cast") + state.baseObjects as List? + } + + //TODO: change this to keep object spans in the correct locations based on ranges. + for (obj in objects!!) { + addObjectSync(obj) + } + + // Collapse the view if necessary + if (!isFocused && allowCollapse) { + post { //Resize the view and display the +x if appropriate + performCollapse(isFocused) + } + } + } + + /** + * Handle saving the token state + */ + private class SavedState : BaseSavedState { + var prefix: CharSequence? = null + var allowCollapse = false + var performBestGuess = false + var preventFreeFormText = false + var tokenClickStyle: TokenClickStyle? = null + var parcelableClassName: String? = null + var baseObjects: List<*>? = null + var tokenizerClassName: String? = null + var tokenizer: Tokenizer? = null + + constructor(parcel: Parcel) : super(parcel) { + prefix = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(parcel) + allowCollapse = parcel.readInt() != 0 + performBestGuess = parcel.readInt() != 0 + preventFreeFormText = parcel.readInt() != 0 + tokenClickStyle = TokenClickStyle.values()[parcel.readInt()] + parcelableClassName = parcel.readString() + baseObjects = if (SERIALIZABLE_PLACEHOLDER == parcelableClassName) { + parcel.readSerializable() as ArrayList<*> + } else { + try { + val loader = Class.forName(parcelableClassName!!).classLoader + parcel.readArrayList(loader) + } catch (ex: ClassNotFoundException) { + //This should really never happen, class had to be available to get here + throw RuntimeException(ex) + } + } + tokenizerClassName = parcel.readString() + tokenizer = try { + val loader = Class.forName(tokenizerClassName!!).classLoader + parcel.readParcelable(loader) + } catch (ex: ClassNotFoundException) { + //This should really never happen, class had to be available to get here + throw RuntimeException(ex) + } + } + + constructor(superState: Parcelable?) : super(superState) + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + TextUtils.writeToParcel(prefix, out, 0) + out.writeInt(if (allowCollapse) 1 else 0) + out.writeInt(if (performBestGuess) 1 else 0) + out.writeInt(if (preventFreeFormText) 1 else 0) + out.writeInt(tokenClickStyle!!.ordinal) + if (SERIALIZABLE_PLACEHOLDER == parcelableClassName) { + out.writeString(SERIALIZABLE_PLACEHOLDER) + out.writeSerializable(baseObjects as Serializable?) + } else { + out.writeString(parcelableClassName) + out.writeList(baseObjects) + } + out.writeString(tokenizer!!.javaClass.canonicalName) + out.writeParcelable(tokenizer, 0) + } + + override fun toString(): String { + val str = ("TokenCompleteTextView.SavedState{" + + Integer.toHexString(System.identityHashCode(this)) + + " tokens=" + baseObjects) + return "$str}" + } + + companion object { + const val SERIALIZABLE_PLACEHOLDER = "Serializable" + @Suppress("unused") + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): SavedState { + return SavedState(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } + } + + /** + * Checks if selection can be deleted. This method is called from TokenInputConnection . + * @param beforeLength the number of characters before the current selection end to check + * @return true if there are no non-deletable pieces of the section + */ + fun canDeleteSelection(beforeLength: Int): Boolean { + if (objects.isEmpty()) return true + + // if beforeLength is 1, we either have no selection or the call is coming from OnKey Event. + // In these scenarios, getSelectionStart() will return the correct value. + val endSelection = selectionEnd + val startSelection = if (beforeLength == 1) selectionStart else endSelection - beforeLength + val text = text + val spans = text.getSpans(0, text.length, TokenImageSpan::class.java) + + // Iterate over all tokens and allow the deletion + // if there are no tokens not removable in the selection + for (span in spans) { + val startTokenSelection = text.getSpanStart(span) + val endTokenSelection = text.getSpanEnd(span) + + // moving on, no need to check this token + @Suppress("unchecked_cast") + if (isTokenRemovable(span.token as T)) continue + if (startSelection == endSelection) { + // Delete single + if (endTokenSelection + 1 == endSelection) { + return false + } + } else { + // Delete range + // Don't delete if a non removable token is in range + if (startSelection <= startTokenSelection + && endTokenSelection + 1 <= endSelection + ) { + return false + } + } + } + return true + } + + private inner class TokenInputConnection( + target: InputConnection?, + mutable: Boolean + ) : InputConnectionWrapper(target, mutable) { + // This will fire if the soft keyboard delete key is pressed. + // The onKeyPressed method does not always do this. + override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { + // Shouldn't be able to delete any text with tokens that are not removable + var fixedBeforeLength = beforeLength + if (!canDeleteSelection(fixedBeforeLength)) return false + + //Shouldn't be able to delete prefix, so don't do anything + if (selectionStart <= prefix!!.length) { + fixedBeforeLength = 0 + return deleteSelectedObject() || super.deleteSurroundingText( + fixedBeforeLength, + afterLength + ) + } + return super.deleteSurroundingText(fixedBeforeLength, afterLength) + } + + override fun setComposingRegion(start: Int, end: Int): Boolean { + //The hint is displayed inline as regular text, but we want to disable normal compose + //functionality on it, so if we attempt to set a composing region on the hint, set the + //composing region to have length of 0, which indicates there is no composing region + //Without this, on many software keyboards, the first word of the hint will be underlined + var fixedStart = start + var fixedEnd = end + if (hintVisible) { + fixedEnd = 0 + fixedStart = fixedEnd + } + return super.setComposingRegion(fixedStart, fixedEnd) + } + + override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean { + //There's an issue with some keyboards where they will try to insert the first word + //of the prefix as the composing text + var fixedText: CharSequence? = text + val hint = hint + if (hint != null && fixedText != null) { + val firstWord = hint.toString().trim { it <= ' ' }.split(" ").toTypedArray()[0] + if (firstWord.isNotEmpty() && firstWord == fixedText.toString()) { + fixedText = "" //It was trying to use th hint, so clear that text + } + } + + //Also, some keyboards don't correctly respect the replacement if the replacement + //is the same number of characters as the replacement span + //We need to ignore this value if it's available + lastCompletionText?.also { lastCompletion -> + fixedText?.also { fixed -> + if (fixed.length == lastCompletion.length + 1 && fixed.toString().startsWith(lastCompletion)) { + fixedText = fixed.subSequence(fixed.length - 1, fixed.length) + lastCompletionText = null + } + } + } + return super.setComposingText(fixedText, newCursorPosition) + } + } + + companion object { + //Logging + const val TAG = "TokenAutoComplete" + } +} \ No newline at end of file diff --git a/library/src/main/java/com/tokenautocomplete/Tokenizer.java b/library/src/main/java/com/tokenautocomplete/Tokenizer.kt similarity index 72% rename from library/src/main/java/com/tokenautocomplete/Tokenizer.java rename to library/src/main/java/com/tokenautocomplete/Tokenizer.kt index d516be72..024d0c82 100644 --- a/library/src/main/java/com/tokenautocomplete/Tokenizer.java +++ b/library/src/main/java/com/tokenautocomplete/Tokenizer.kt @@ -1,11 +1,8 @@ -package com.tokenautocomplete; +package com.tokenautocomplete -import android.os.Parcelable; -import androidx.annotation.NonNull; +import android.os.Parcelable -import java.util.List; - -public interface Tokenizer extends Parcelable { +interface Tokenizer : Parcelable { /** * Find all ranges that can be tokenized. This system should detect possible tokens * both with and without having had wrapTokenValue called on the token string representation @@ -15,8 +12,7 @@ public interface Tokenizer extends Parcelable { * @param end where the tokenizer should stop looking for tokens * @return all ranges of characters that are valid tokens */ - @NonNull - List findTokenRanges(CharSequence charSequence, int start, int end); + fun findTokenRanges(charSequence: CharSequence, start: Int, end: Int): List /** * Return a complete string representation of the token. Often used to add commas after email @@ -27,13 +23,12 @@ public interface Tokenizer extends Parcelable { * @param unwrappedTokenValue the value to wrap * @return the token value with any expected delimiter characters */ - @NonNull - CharSequence wrapTokenValue(CharSequence unwrappedTokenValue); + fun wrapTokenValue(unwrappedTokenValue: CharSequence): CharSequence /** * Return true if there is a character in the charSequence that should trigger token detection * @param charSequence source text to look at * @return true if charSequence contains a value that should end a token */ - boolean containsTokenTerminator(CharSequence charSequence); -} + fun containsTokenTerminator(charSequence: CharSequence): Boolean +} \ No newline at end of file diff --git a/library/src/main/java/com/tokenautocomplete/ViewSpan.java b/library/src/main/java/com/tokenautocomplete/ViewSpan.java deleted file mode 100644 index 63023d21..00000000 --- a/library/src/main/java/com/tokenautocomplete/ViewSpan.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.tokenautocomplete; - -import android.graphics.Canvas; -import android.graphics.Paint; -import androidx.annotation.IntRange; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.text.style.ReplacementSpan; -import android.view.View; -import android.view.ViewGroup; - -/** - * Span that holds a view it draws when rendering - * - * Created on 2/3/15. - * @author mgod - */ -public class ViewSpan extends ReplacementSpan { - protected View view; - private ViewSpan.Layout layout; - private int cachedMaxWidth = -1; - - @SuppressWarnings("WeakerAccess") - public ViewSpan(View view, ViewSpan.Layout layout) { - super(); - this.layout = layout; - this.view = view; - this.view.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT)); - } - - private void prepView() { - if (layout.getMaxViewSpanWidth() != cachedMaxWidth || view.isLayoutRequested()) { - cachedMaxWidth = layout.getMaxViewSpanWidth(); - - int spec = View.MeasureSpec.AT_MOST; - if (cachedMaxWidth == 0) { - //If the width is 0, allow the view to choose it's own content size - spec = View.MeasureSpec.UNSPECIFIED; - } - int widthSpec = View.MeasureSpec.makeMeasureSpec(cachedMaxWidth, spec); - int heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); - - view.measure(widthSpec, heightSpec); - view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight()); - } - } - - @Override - public void draw(@NonNull Canvas canvas, CharSequence text, @IntRange(from = 0) int start, - @IntRange(from = 0) int end, float x, int top, int y, int bottom, @NonNull Paint paint) { - prepView(); - - canvas.save(); - canvas.translate(x, top); - view.draw(canvas); - canvas.restore(); - } - - @Override - public int getSize(@NonNull Paint paint, CharSequence charSequence, @IntRange(from = 0) int start, - @IntRange(from = 0) int end, @Nullable Paint.FontMetricsInt fontMetricsInt) { - prepView(); - - if (fontMetricsInt != null) { - //We need to make sure the layout allots enough space for the view - int height = view.getMeasuredHeight(); - - int adjustedBaseline = view.getBaseline(); - //-1 means the view doesn't support baseline alignment, so align bottom to font baseline - if (adjustedBaseline == -1) { - adjustedBaseline = height; - } - fontMetricsInt.ascent = fontMetricsInt.top = -adjustedBaseline; - fontMetricsInt.descent = fontMetricsInt.bottom = height - adjustedBaseline; - } - - return view.getRight(); - } - - public interface Layout { - int getMaxViewSpanWidth(); - } -} diff --git a/library/src/main/java/com/tokenautocomplete/ViewSpan.kt b/library/src/main/java/com/tokenautocomplete/ViewSpan.kt new file mode 100644 index 00000000..a742cb36 --- /dev/null +++ b/library/src/main/java/com/tokenautocomplete/ViewSpan.kt @@ -0,0 +1,76 @@ +package com.tokenautocomplete + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Paint.FontMetricsInt +import android.text.style.ReplacementSpan +import android.view.View +import android.view.ViewGroup +import androidx.annotation.IntRange + +/** + * Span that holds a view it draws when rendering + * + * Created on 2/3/15. + * @author mgod + */ +open class ViewSpan(var view: View, private val layout: Layout) : ReplacementSpan() { + private var cachedMaxWidth = -1 + private fun prepView() { + if (layout.maxViewSpanWidth != cachedMaxWidth || view.isLayoutRequested) { + cachedMaxWidth = layout.maxViewSpanWidth + var spec = View.MeasureSpec.AT_MOST + if (cachedMaxWidth == 0) { + //If the width is 0, allow the view to choose it's own content size + spec = View.MeasureSpec.UNSPECIFIED + } + val widthSpec = View.MeasureSpec.makeMeasureSpec(cachedMaxWidth, spec) + val heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + view.measure(widthSpec, heightSpec) + view.layout(0, 0, view.measuredWidth, view.measuredHeight) + } + } + + override fun draw( + canvas: Canvas, text: CharSequence, @IntRange(from = 0) start: Int, + @IntRange(from = 0) end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint + ) { + prepView() + canvas.save() + canvas.translate(x, top.toFloat()) + view.draw(canvas) + canvas.restore() + } + + override fun getSize( + paint: Paint, charSequence: CharSequence, @IntRange(from = 0) start: Int, + @IntRange(from = 0) end: Int, fontMetricsInt: FontMetricsInt? + ): Int { + prepView() + if (fontMetricsInt != null) { + //We need to make sure the layout allots enough space for the view + val height = view.measuredHeight + var adjustedBaseline = view.baseline + //-1 means the view doesn't support baseline alignment, so align bottom to font baseline + if (adjustedBaseline == -1) { + adjustedBaseline = height + } + fontMetricsInt.top = -adjustedBaseline + fontMetricsInt.ascent = fontMetricsInt.top + fontMetricsInt.bottom = height - adjustedBaseline + fontMetricsInt.descent = fontMetricsInt.bottom + } + return view.right + } + + interface Layout { + val maxViewSpanWidth: Int + } + + init { + view.layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } +} \ No newline at end of file From da7edd07b5d830a940116bdd97e14d6ab150aedd Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Fri, 13 Aug 2021 16:51:03 -0400 Subject: [PATCH 09/33] Fix comma completions for API 30+ --- .../TokenCompleteTextView.kt | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt index 89b01259..04968ee5 100644 --- a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt +++ b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt @@ -3,6 +3,7 @@ package com.tokenautocomplete import android.content.Context import android.graphics.Rect import android.graphics.Typeface +import android.os.Build import android.os.Parcel import android.os.Parcelable import android.text.Editable @@ -79,6 +80,15 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On private var internalEditInProgress = false private var tokenLimit = -1 + /** + * Android M/API 30 introduced a change to the SpannableStringBuilder that triggers additional + * text change callbacks when we do our token replacement. It's supposed to report if it's a + * recursive call to the callbacks to let the recipient handle nested calls differently, but + * for some reason, in our case the first and second callbacks both report a depth of 1 and only + * on the third callback do we get a depth of 2, so we need to track this ourselves. + */ + private var ignoreNextTextCommit = false + @Transient private var lastCompletionText: String? = null @@ -814,6 +824,9 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } if (editable != null) { internalEditInProgress = true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + ignoreNextTextCommit = true + } if (tokenSpan == null) { editable.replace(candidateRange.start, candidateRange.end, "") } else if (shouldIgnoreToken(tokenSpan.token)) { @@ -1172,6 +1185,12 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On private inner class TokenTextWatcher : TextWatcher { var spansToRemove = ArrayList() override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + if (internalEditInProgress || ignoreNextTextCommit) return + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (s is SpannableStringBuilder && s.textWatcherDepth > 1) return + } + // count > 0 means something will be deleted if (count > 0 && text != null) { val text = text @@ -1192,13 +1211,16 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } override fun afterTextChanged(text: Editable) { - val spansCopy = ArrayList(spansToRemove) - spansToRemove.clear() - for (token in spansCopy) { - //Only remove it if it's still present - if (text.getSpanStart(token) != -1 && text.getSpanEnd(token) != -1) { - removeSpan(text, token) + if (!internalEditInProgress) { + val spansCopy = ArrayList(spansToRemove) + spansToRemove.clear() + for (token in spansCopy) { + //Only remove it if it's still present + if (text.getSpanStart(token) != -1 && text.getSpanEnd(token) != -1) { + removeSpan(text, token) + } } + ignoreNextTextCommit = false } clearSelections() updateHint() From b1be71741da0d66e19cf14f03399ab8f2355bd99 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Fri, 13 Aug 2021 17:05:51 -0400 Subject: [PATCH 10/33] Test on wider range of APIs --- .github/workflows/android.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 3ba3cef6..e4aeba87 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -10,6 +10,9 @@ jobs: build: runs-on: ubuntu-latest + strategy: + matrix: + api-version: [23, 29, 30] steps: - uses: actions/checkout@v2 @@ -32,5 +35,6 @@ jobs: gcloud auth activate-service-account --key-file=${HOME}/gcloud-service-key.json gcloud --quiet config set project $GOOGLE_PROJECT_ID - name: Run Instrumented Tests with Firebase Test Lab - run: gcloud firebase test android run --type instrumentation --app example/build/outputs/apk/debug/example-debug.apk --test example/build/outputs/apk/androidTest/debug/example-debug-androidTest.apk --device model=Nexus6,version=23,locale=en,orientation=portrait --timeout 30m - + run: gcloud firebase test android run --type instrumentation --app example/build/outputs/apk/debug/example-debug.apk --test example/build/outputs/apk/androidTest/debug/example-debug-androidTest.apk --device model=NexusLowRes,version=API_VERSION,locale=en,orientation=portrait --timeout 30m + env: + API_VERSION: ${{ matrix.api-version }} From 44e36c2944acd10d937b7836fc0257ec0d3363c9 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Fri, 20 Aug 2021 17:16:10 -0400 Subject: [PATCH 11/33] Add input connection logger for debugging --- .../LoggedInputConnectionWrapper.kt | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 library/src/main/java/com/tokenautocomplete/LoggedInputConnectionWrapper.kt diff --git a/library/src/main/java/com/tokenautocomplete/LoggedInputConnectionWrapper.kt b/library/src/main/java/com/tokenautocomplete/LoggedInputConnectionWrapper.kt new file mode 100644 index 00000000..817bedcb --- /dev/null +++ b/library/src/main/java/com/tokenautocomplete/LoggedInputConnectionWrapper.kt @@ -0,0 +1,151 @@ +package com.tokenautocomplete + +import android.os.Bundle +import android.os.Handler +import android.util.Log +import android.view.KeyEvent +import android.view.inputmethod.CompletionInfo +import android.view.inputmethod.CorrectionInfo +import android.view.inputmethod.ExtractedText +import android.view.inputmethod.ExtractedTextRequest +import android.view.inputmethod.InputConnection +import android.view.inputmethod.InputConnectionWrapper +import android.view.inputmethod.InputContentInfo + +class LoggedInputConnectionWrapper(target: InputConnection?, + mutable: Boolean +) : InputConnectionWrapper(target, mutable) { + override fun getTextBeforeCursor(n: Int, flags: Int): CharSequence? { + Log.d("TOKEN_INPUT", "getTextBeforeCursor($n, $flags))") + return super.getTextBeforeCursor(n, flags) + } + + override fun getTextAfterCursor(n: Int, flags: Int): CharSequence? { + Log.d("TOKEN_INPUT", "getTextAfterCursor($n, $flags)") + return super.getTextAfterCursor(n, flags) + } + + override fun getSelectedText(flags: Int): CharSequence? { + Log.d("TOKEN_INPUT", "getSelectedText($flags)") + return super.getSelectedText(flags) + } + + override fun getCursorCapsMode(reqModes: Int): Int { + Log.d("TOKEN_INPUT", "getCursorCapsMode($reqModes)") + return super.getCursorCapsMode(reqModes) + } + + override fun getExtractedText(request: ExtractedTextRequest?, flags: Int): ExtractedText? { + Log.d("TOKEN_INPUT", "getExtractedText($request, $flags)") + return super.getExtractedText(request, flags) + } + + override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { + Log.d("TOKEN_INPUT", "deleteSurroundingText($beforeLength, $afterLength)") + return super.deleteSurroundingText(beforeLength, afterLength) + } + + override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean { + Log.d("TOKEN_INPUT", "deleteSurroundingTextInCodePoints($beforeLength, $afterLength)") + return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength) + } + + override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean { + Log.d("TOKEN_INPUT", "setComposingText($text, $newCursorPosition)") + return super.setComposingText(text, newCursorPosition) + } + + override fun setComposingRegion(start: Int, end: Int): Boolean { + Log.d("TOKEN_INPUT", "setComposingRegion($start, $end)") + return super.setComposingRegion(start, end) + } + + override fun finishComposingText(): Boolean { + Log.d("TOKEN_INPUT", "finishComposingText()") + return super.finishComposingText() + } + + override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean { + Log.d("TOKEN_INPUT", "commitText($text, $newCursorPosition)") + return super.commitText(text, newCursorPosition) + } + + override fun commitCompletion(text: CompletionInfo?): Boolean { + Log.d("TOKEN_INPUT", "commitCompletion($text)") + return super.commitCompletion(text) + } + + override fun commitCorrection(correctionInfo: CorrectionInfo?): Boolean { + Log.d("TOKEN_INPUT", "commitCorrection($correctionInfo)") + return super.commitCorrection(correctionInfo) + } + + override fun setSelection(start: Int, end: Int): Boolean { + Log.d("TOKEN_INPUT", "setSelection($start, $end)") + return super.setSelection(start, end) + } + + override fun performEditorAction(editorAction: Int): Boolean { + Log.d("TOKEN_INPUT", "performEditorAction($editorAction)") + return super.performEditorAction(editorAction) + } + + override fun performContextMenuAction(id: Int): Boolean { + Log.d("TOKEN_INPUT", "performContextMenuAction($id)") + return super.performContextMenuAction(id) + } + + override fun beginBatchEdit(): Boolean { + Log.d("TOKEN_INPUT", "beginBatchEdit()") + return super.beginBatchEdit() + } + + override fun endBatchEdit(): Boolean { + Log.d("TOKEN_INPUT", "endBatchEdit()") + return super.endBatchEdit() + } + + override fun sendKeyEvent(event: KeyEvent?): Boolean { + Log.d("TOKEN_INPUT", "sendKeyEvent($event)") + return super.sendKeyEvent(event) + } + + override fun clearMetaKeyStates(states: Int): Boolean { + Log.d("TOKEN_INPUT", "clearMetaKeyStates($states)") + return super.clearMetaKeyStates(states) + } + + override fun reportFullscreenMode(enabled: Boolean): Boolean { + Log.d("TOKEN_INPUT", "reportFullscreenMode($enabled)") + return super.reportFullscreenMode(enabled) + } + + override fun performPrivateCommand(action: String?, data: Bundle?): Boolean { + Log.d("TOKEN_INPUT", "performPrivateCommand($action, $data)") + return super.performPrivateCommand(action, data) + } + + override fun requestCursorUpdates(cursorUpdateMode: Int): Boolean { + Log.d("TOKEN_INPUT", "requestCursorUpdates($cursorUpdateMode)") + return super.requestCursorUpdates(cursorUpdateMode) + } + + override fun getHandler(): Handler? { + Log.d("TOKEN_INPUT", "getHandler()") + return super.getHandler() + } + + override fun closeConnection() { + Log.d("TOKEN_INPUT", "closeConnection()") + super.closeConnection() + } + + override fun commitContent( + inputContentInfo: InputContentInfo, + flags: Int, + opts: Bundle? + ): Boolean { + Log.d("TOKEN_INPUT", "commitContent($inputContentInfo, $flags, $opts)") + return super.commitContent(inputContentInfo, flags, opts) + } +} \ No newline at end of file From ebe6cb406e700c0c21e06dfaacf202142ed1a9af Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Fri, 20 Aug 2021 17:16:33 -0400 Subject: [PATCH 12/33] Move to target API 29 --- gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index aa1dd2c4..5a8c3cf3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,8 +16,8 @@ VERSION_NAME=4.0.0-beta02 VERSION_CODE=26 GROUP=com.splitwise ANDROID_BUILD_MIN_SDK_VERSION=14 -ANDROID_BUILD_TARGET_SDK_VERSION=28 -ANDROID_BUILD_SDK_VERSION=28 +ANDROID_BUILD_TARGET_SDK_VERSION=29 +ANDROID_BUILD_SDK_VERSION=29 ANDROIDX_VERSION=1.1.0 KOTLIN_VERSION=1.5.21 POM_DESCRIPTION=Android Token AutoComplete EditText From c7652451027f2d8ba885c6e9a3e5356b15209dc6 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Fri, 20 Aug 2021 17:17:19 -0400 Subject: [PATCH 13/33] Detect hint directly --- .../tokenautocomplete/TokenCompleteTextView.kt | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt index 04968ee5..725ec620 100644 --- a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt +++ b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt @@ -69,7 +69,6 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On private var hiddenContent: SpannableStringBuilder? = null private var tokenClickStyle: TokenClickStyle? = TokenClickStyle.None private var prefix: CharSequence? = "" - private var hintVisible = false private var lastLayout: Layout? = null private var initialized = false private var performBestGuess = true @@ -92,6 +91,11 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On @Transient private var lastCompletionText: String? = null + private val hintVisible: Boolean + get() { + return text.getSpans(0, text.length, HintSpan::class.java).isNotEmpty() + } + /** * Add the TextChangedListeners */ @@ -1062,7 +1066,6 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On testLength += text.getSpanEnd(hint) - text.getSpanStart(hint) } if (text.length == testLength) { - hintVisible = true if (hint != null) { return //hint already visible } @@ -1076,13 +1079,9 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On val colors = hintTextColors val hintSpan = HintSpan(null, style, textSize.toInt(), colors, colors) internalEditInProgress = true - text.insert(prefix!!.length, hintText) - text.setSpan( - hintSpan, - prefix!!.length, - prefix!!.length + getHint().length, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) + val spannedHint = SpannableString(hintText) + spannedHint.setSpan(hintSpan, 0, spannedHint.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + text.insert(prefix!!.length, spannedHint) internalEditInProgress = false setSelection(prefix!!.length) } else { @@ -1097,7 +1096,6 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On text.removeSpan(hint) text.replace(sStart, sEnd, "") internalEditInProgress = false - hintVisible = false } } } From dfd88f5e3aed8a5016ec60157c2368c35e6aefa2 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Fri, 20 Aug 2021 17:17:47 -0400 Subject: [PATCH 14/33] Fix API 29 compilation issue --- .../main/java/com/tokenautocomplete/TokenCompleteTextView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt index 725ec620..486eeb90 100644 --- a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt +++ b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt @@ -1261,7 +1261,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On //Figure out what class of objects we have var viewClass: Class<*> = javaClass while (viewClass.superclass != TokenCompleteTextView::class.java) { - viewClass = viewClass.superclass + viewClass = viewClass.superclass as Class<*> } // This operation is safe. Because viewClass is a direct sub-class, getGenericSuperclass() will From 3c4ca13e17ede09056f1d1e43d6c6f4e278ff702 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Fri, 20 Aug 2021 17:18:14 -0400 Subject: [PATCH 15/33] Add work-around for API 26-29 hint failure on first completion With an empty text field with a hint and prefix using the stock emulator keyboard, the first completion will not complete and instead erase all user input. This work-around postpones updating the hint until after the whole completion process is finished to prevent the hint from overwriting this first set of input. Without this, you would type "ma," in the sample project and everything gets erased --- .../TokenCompleteTextView.kt | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt index 486eeb90..6d30dbb1 100644 --- a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt +++ b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt @@ -77,6 +77,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On private var shouldFocusNext = false private var allowCollapse = true private var internalEditInProgress = false + private var inBatchEditAPI26to29Workaround = false private var tokenLimit = -1 /** @@ -1220,8 +1221,12 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } ignoreNextTextCommit = false } + clearSelections() - updateHint() + + if (!inBatchEditAPI26to29Workaround) { + updateHint() + } } override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} @@ -1477,6 +1482,30 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On target: InputConnection?, mutable: Boolean ) : InputConnectionWrapper(target, mutable) { + + private val needsWorkaround: Boolean + get() { + return Build.VERSION_CODES.O <= Build.VERSION.SDK_INT && + Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q + + } + + override fun beginBatchEdit(): Boolean { + if (needsWorkaround) { + inBatchEditAPI26to29Workaround = true + } + return super.beginBatchEdit() + } + + override fun endBatchEdit(): Boolean { + val result = super.endBatchEdit() + if (needsWorkaround) { + inBatchEditAPI26to29Workaround = false + post { updateHint() } + } + return result + } + // This will fire if the soft keyboard delete key is pressed. // The onKeyPressed method does not always do this. override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { From 7cc0d9ad536c9627415b0b80870fd76ffcd0f7c0 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Fri, 20 Aug 2021 17:20:41 -0400 Subject: [PATCH 16/33] Test a much wider range of Android APIs --- .github/workflows/android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index e4aeba87..eb453704 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - api-version: [23, 29, 30] + api-version: [23, 24, 25, 26, 27, 28, 29, 30] steps: - uses: actions/checkout@v2 From 9b4ffe7053ed6086004673a7529f1cc3785c9191 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Mon, 23 Aug 2021 13:47:52 -0400 Subject: [PATCH 17/33] Suppress erroneous parcel creator lint --- .../src/main/java/com/tokenautocomplete/CharacterTokenizer.kt | 4 +++- library/src/main/java/com/tokenautocomplete/TagTokenizer.kt | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/library/src/main/java/com/tokenautocomplete/CharacterTokenizer.kt b/library/src/main/java/com/tokenautocomplete/CharacterTokenizer.kt index f840e110..549b3088 100644 --- a/library/src/main/java/com/tokenautocomplete/CharacterTokenizer.kt +++ b/library/src/main/java/com/tokenautocomplete/CharacterTokenizer.kt @@ -1,5 +1,7 @@ package com.tokenautocomplete +import android.annotation.SuppressLint +import android.os.Parcelable import android.text.SpannableString import android.text.Spanned import android.text.TextUtils @@ -13,8 +15,8 @@ import java.util.* * @author mgod */ @Parcelize +@SuppressLint("ParcelCreator") open class CharacterTokenizer(private val splitChar: List, private val tokenTerminator: String) : Tokenizer { - override fun containsTokenTerminator(charSequence: CharSequence): Boolean { for (element in charSequence) { if (splitChar.contains(element)) { diff --git a/library/src/main/java/com/tokenautocomplete/TagTokenizer.kt b/library/src/main/java/com/tokenautocomplete/TagTokenizer.kt index 44a0bf5c..8c9713c4 100644 --- a/library/src/main/java/com/tokenautocomplete/TagTokenizer.kt +++ b/library/src/main/java/com/tokenautocomplete/TagTokenizer.kt @@ -1,9 +1,11 @@ package com.tokenautocomplete +import android.annotation.SuppressLint import kotlinx.parcelize.Parcelize import java.util.* @Parcelize +@SuppressLint("ParcelCreator") open class TagTokenizer constructor(private val tagPrefixes: List) : Tokenizer { internal constructor() : this(listOf('@', '#')) From e3c2c751f4d121eb373c57cec7fd224ac6ccdce2 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Mon, 23 Aug 2021 13:55:55 -0400 Subject: [PATCH 18/33] Fix CI configuration --- .github/workflows/android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index eb453704..29653361 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -35,6 +35,6 @@ jobs: gcloud auth activate-service-account --key-file=${HOME}/gcloud-service-key.json gcloud --quiet config set project $GOOGLE_PROJECT_ID - name: Run Instrumented Tests with Firebase Test Lab - run: gcloud firebase test android run --type instrumentation --app example/build/outputs/apk/debug/example-debug.apk --test example/build/outputs/apk/androidTest/debug/example-debug-androidTest.apk --device model=NexusLowRes,version=API_VERSION,locale=en,orientation=portrait --timeout 30m + run: gcloud firebase test android run --type instrumentation --app example/build/outputs/apk/debug/example-debug.apk --test example/build/outputs/apk/androidTest/debug/example-debug-androidTest.apk --device model=NexusLowRes,version=$API_VERSION,locale=en,orientation=portrait --timeout 30m env: API_VERSION: ${{ matrix.api-version }} From 68e876ee5e055532a492a68b50dd5dd41dd474b6 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Tue, 24 Aug 2021 10:31:30 -0400 Subject: [PATCH 19/33] Fix cursor position when removing hint --- .../src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt index 6d30dbb1..d5c4c8e1 100644 --- a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt +++ b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt @@ -1096,6 +1096,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On internalEditInProgress = true text.removeSpan(hint) text.replace(sStart, sEnd, "") + setSelection(sStart) internalEditInProgress = false } } From bfc702f7bba52ab0f0ba6310b74f1352604e7a0d Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Mon, 23 Aug 2021 17:03:06 -0400 Subject: [PATCH 20/33] Update null handling for more explicit Kotlin null checks --- .../TokenCompleteTextView.kt | 188 +++++++++--------- 1 file changed, 92 insertions(+), 96 deletions(-) diff --git a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt index d5c4c8e1..f3d3f391 100644 --- a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt +++ b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt @@ -68,7 +68,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On private var countSpan: CountSpan = CountSpan() private var hiddenContent: SpannableStringBuilder? = null private var tokenClickStyle: TokenClickStyle? = TokenClickStyle.None - private var prefix: CharSequence? = "" + private var prefix: CharSequence? = null private var lastLayout: Layout? = null private var initialized = false private var performBestGuess = true @@ -168,19 +168,21 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } //We need to not do anything when we would delete the prefix - if (destinationStart < prefix!!.length) { - //when setText is called, which should only be called during restoring, - //destinationStart and destinationEnd are 0. If not checked, it will clear out - //the prefix. - //This is why we need to return null in this if condition to preserve state. - if (destinationStart == 0 && destinationEnd == 0) { - return@InputFilter null - } else return@InputFilter if (destinationEnd <= prefix!!.length) { - //Don't do anything - prefix!!.subSequence(destinationStart, destinationEnd) - } else { - //Delete everything up to the prefix - prefix!!.subSequence(destinationStart, prefix!!.length) + prefix?.also { prefix -> + if (destinationStart < prefix.length) { + //when setText is called, which should only be called during restoring, + //destinationStart and destinationEnd are 0. If not checked, it will clear out + //the prefix. + //This is why we need to return null in this if condition to preserve state. + if (destinationStart == 0 && destinationEnd == 0) { + return@InputFilter null + } else return@InputFilter if (destinationEnd <= prefix.length) { + //Don't do anything + prefix.subSequence(destinationStart, destinationEnd) + } else { + //Delete everything up to the prefix + prefix.subSequence(destinationStart, prefix.length) + } } } null @@ -209,7 +211,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On filter?.filter(currentCompletionText(), this) } - fun setTokenizer(t: Tokenizer?) { + fun setTokenizer(t: Tokenizer) { tokenizer = t } @@ -218,7 +220,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On * * @param cStyle The TokenClickStyle */ - fun setTokenClickStyle(cStyle: TokenClickStyle?) { + fun setTokenClickStyle(cStyle: TokenClickStyle) { tokenClickStyle = cStyle } @@ -256,17 +258,17 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On * @param p String with the hint */ @Suppress("MemberVisibilityCanBePrivate") - fun setPrefix(p: CharSequence?) { + fun setPrefix(p: CharSequence) { //Have to clear and set the actual text before saving the prefix to avoid the prefix filter val prevPrefix = prefix prefix = p val text = text if (text != null) { internalEditInProgress = true - if (prevPrefix != null) { - text.replace(0, prevPrefix.length, p) - } else { + if (prevPrefix.isNullOrEmpty()) { text.insert(0, p) + } else { + text.replace(0, prevPrefix.length, p) } internalEditInProgress = false } @@ -291,7 +293,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On * @param prefix prefix * @param color A single color value in the form 0xAARRGGBB. */ - fun setPrefix(prefix: CharSequence?, color: Int) { + fun setPrefix(prefix: CharSequence, color: Int) { val spannablePrefix = SpannableString(prefix) spannablePrefix.setSpan(ForegroundColorSpan(color), 0, spannablePrefix.length, 0) setPrefix(spannablePrefix) @@ -368,10 +370,10 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On /** * A token view for the object * - * @param object the object selected by the user from the list + * @param obj the object selected by the user from the list * @return a view to display a token in the text field for the object */ - protected abstract fun getViewForObject(`object`: T): View + protected abstract fun getViewForObject(obj: T): View? /** * Provides a default completion when the user hits , and there is no item in the completion @@ -380,10 +382,10 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On * @param completionText the current text we are completing against * @return a best guess for what the user meant to complete or null if you don't want a guess */ - protected abstract fun defaultObject(completionText: String?): T//See if this is where we should start the selection + protected abstract fun defaultObject(completionText: String): T? //Replace token spans -//Need to take the existing tet buffer and + //Need to take the existing tet buffer and // - replace all tokens with a decent string representation of the object // - set the selection span to the corresponding location in the new CharSequence /** @@ -477,7 +479,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On get() { val editable = text val cursorEndPosition = selectionEnd - var candidateStringStart = prefix!!.length + var candidateStringStart = prefix?.length ?: 0 var candidateStringEnd = editable.length if (hintVisible) { //Don't try to search the hint for possible tokenizable strings @@ -485,7 +487,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } //We want to find the largest string that contains the selection end that is not already tokenized - val spans = editable.getSpans(prefix!!.length, editable.length, TokenImageSpan::class.java) + val spans = editable.getSpans(prefix?.length ?: 0, editable.length, TokenImageSpan::class.java) for (span in spans) { val spanEnd = editable.getSpanEnd(span) if (spanEnd in (candidateStringStart + 1)..cursorEndPosition) { @@ -569,7 +571,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On override fun performCompletion() { if ((adapter == null || listSelection == ListView.INVALID_POSITION) && enoughToFilter()) { - val bestGuess: Any = if (adapter != null && adapter.count > 0 && performBestGuess) { + val bestGuess: Any? = if (adapter != null && adapter.count > 0 && performBestGuess) { adapter.getItem(0) } else { defaultObject(currentCompletionText()) @@ -625,7 +627,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } private fun deleteSelectedObject(): Boolean { - if (tokenClickStyle != null && tokenClickStyle!!.isSelectable) { + if (tokenClickStyle?.isSelectable == true) { val text = text ?: return false @Suppress("unchecked_cast") val spans: Array = @@ -684,15 +686,15 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } //Never let users select text val selectionEnd = selectionStart - if (tokenClickStyle != null && tokenClickStyle!!.isSelectable) { + if (tokenClickStyle?.isSelectable == true) { val text = text if (text != null) { clearSelections() } } - if (prefix != null && (selectionStart < prefix!!.length || selectionEnd < prefix!!.length)) { + if (selectionStart < prefix?.length ?: 0 || selectionEnd < prefix?.length ?: 0) { //Don't let users select the prefix - setSelection(prefix!!.length) + setSelection(prefix?.length ?: 0) } else { val text = text if (text != null) { @@ -772,7 +774,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On ) hiddenContent = null if (hintVisible) { - setSelection(prefix!!.length) + setSelection(prefix?.length ?: 0) } else { post { setSelection(text.length) } } @@ -798,9 +800,9 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On if (allowCollapse) performCollapse(hasFocus) } - override fun convertSelectionToString(selectedObject: Any): CharSequence { + override fun convertSelectionToString(selectedObject: Any?): CharSequence { @Suppress("unchecked_cast") - this.selectedObject = selectedObject as T + this.selectedObject = selectedObject as T? return "" } @@ -809,15 +811,14 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On if (obj == null) { return null } - val tokenView = getViewForObject(obj) - return TokenImageSpan(tokenView, obj) + return getViewForObject(obj)?.let { TokenImageSpan(it, obj) } } override fun replaceText(ignore: CharSequence) { clearComposingText() // Don't build a token for an empty String - if (selectedObject == null || selectedObject.toString() == "") return + if (selectedObject?.toString().isNullOrEmpty()) return val tokenSpan = buildSpanForObject(selectedObject) val editable = text val candidateRange = currentCandidateTokenRange @@ -837,7 +838,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } else if (shouldIgnoreToken(tokenSpan.token)) { editable.replace(candidateRange.start, candidateRange.end, "") if (listener != null) { - listener!!.onTokenIgnored(tokenSpan.token) + listener?.onTokenIgnored(tokenSpan.token) } } else { val ssb = SpannableStringBuilder(tokenizer!!.wrapTokenValue(tokenToString(tokenSpan.token))) @@ -866,29 +867,28 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On /** * Append a token object to the object list. May only be called from the main thread. * - * @param object the object to add to the displayed tokens + * @param obj the object to add to the displayed tokens */ @UiThread - fun addObjectSync(`object`: T?) { - if (`object` == null) return - if (shouldIgnoreToken(`object`)) { + fun addObjectSync(obj: T) { + if (shouldIgnoreToken(obj)) { if (listener != null) { - listener!!.onTokenIgnored(`object`) + listener?.onTokenIgnored(obj) } return } if (tokenLimit != -1 && objects.size == tokenLimit) return - insertSpan(buildSpanForObject(`object`)) + buildSpanForObject(obj)?.also { insertSpan(it) } if (text != null && isFocused) setSelection(text.length) } /** * Append a token object to the object list. Object will be added on the main thread. * - * @param object the object to add to the displayed tokens + * @param obj the object to add to the displayed tokens */ - fun addObjectAsync(`object`: T) { - post { addObjectSync(`object`) } + fun addObjectAsync(obj: T) { + post { addObjectSync(obj) } } /** @@ -896,17 +896,15 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On * object is present in the view. Uses [Object.equals] to find objects. May only * be called from the main thread * - * @param object object to remove, may be null or not in the view + * @param obj object to remove, may be null or not in the view */ @UiThread - fun removeObjectSync(`object`: T) { + fun removeObjectSync(obj: T) { //To make sure all the appropriate callbacks happen, we just want to piggyback on the //existing code that handles deleting spans when the text changes val texts = ArrayList() //If there is hidden content, it's important that we update it first - if (hiddenContent != null) { - texts.add(hiddenContent!!) - } + hiddenContent?.also { texts.add(it) } if (text != null) { texts.add(text) } @@ -917,7 +915,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On val spans: Array = text.getSpans(0, text.length, TokenImageSpan::class.java) as Array for (span in spans) { - if (span.token == `object`) { + if (span.token == obj) { removeSpan(text, span) } } @@ -930,10 +928,10 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On * object is present in the view. Uses [Object.equals] to find objects. Object * will be added on the main thread * - * @param object object to remove, may be null or not in the view + * @param obj object to remove, may be null or not in the view */ - fun removeObjectAsync(`object`: T) { - post { removeObjectSync(`object`) } + fun removeObjectAsync(obj: T) { + post { removeObjectSync(obj) } } /** @@ -941,8 +939,8 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On */ fun clearAsync() { post { - for (`object` in objects) { - removeObjectSync(`object`) + for (obj in objects) { + removeObjectSync(obj) } } } @@ -1002,8 +1000,8 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On * * @param tokenSpan span to insert */ - private fun insertSpan(tokenSpan: TokenImageSpan?) { - val ssb = tokenizer!!.wrapTokenValue(tokenToString(tokenSpan!!.token)) + private fun insertSpan(tokenSpan: TokenImageSpan) { + val ssb = tokenizer!!.wrapTokenValue(tokenToString(tokenSpan.token)) val editable = text ?: return // If we haven't hidden any objects yet, we can try adding it @@ -1013,7 +1011,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On //There might be a hint visible... if (hintVisible) { //...so we need to put the object in in front of the hint - offset = prefix!!.length + offset = prefix?.length ?: 0 } else { val currentRange = currentCandidateTokenRange if (currentRange.length() > 0) { @@ -1061,7 +1059,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On if (prefix?.isNotEmpty() == true) { val hints = text.getSpans(0, text.length, HintSpan::class.java) var hint: HintSpan? = null - var testLength = prefix!!.length + var testLength = prefix?.length ?: 0 if (hints.isNotEmpty()) { hint = hints[0] testLength += text.getSpanEnd(hint) - text.getSpanStart(hint) @@ -1082,9 +1080,9 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On internalEditInProgress = true val spannedHint = SpannableString(hintText) spannedHint.setSpan(hintSpan, 0, spannedHint.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - text.insert(prefix!!.length, spannedHint) + text.insert(prefix?.length ?: 0, spannedHint) internalEditInProgress = false - setSelection(prefix!!.length) + setSelection(prefix?.length ?: 0) } else { if (hint == null) { return //hint already removed @@ -1103,7 +1101,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } private fun clearSelections() { - if (tokenClickStyle == null || !tokenClickStyle!!.isSelectable) return + if (tokenClickStyle?.isSelectable != true) return val text = text ?: return @Suppress("unchecked_cast") val tokens: Array = @@ -1144,9 +1142,6 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On //Make sure the selection is not in the middle of the span setSelection(text.getSpanEnd(this)) } - else -> if (selectionStart != text.getSpanEnd(this)) { - setSelection(text.getSpanEnd(this)) - } } } } @@ -1164,14 +1159,14 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On // If we're not focused: collapse the view if necessary if (!isFocused && allowCollapse) performCollapse(false) @Suppress("unchecked_cast") - if (listener != null) listener!!.onTokenAdded(what.token as T) + if (listener != null) listener?.onTokenAdded(what.token as T) } } override fun onSpanRemoved(text: Spannable, what: Any, start: Int, end: Int) { if (what is TokenCompleteTextView<*>.TokenImageSpan && !savingState) { @Suppress("unchecked_cast") - if (listener != null) listener!!.onTokenRemoved(what.token as T) + if (listener != null) listener?.onTokenRemoved(what.token as T) } } @@ -1234,25 +1229,24 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } @Suppress("MemberVisibilityCanBePrivate") - protected val serializableObjects: List - get() { - val serializables: MutableList = ArrayList() - for (obj in objects) { - if (obj is Serializable) { - serializables.add(obj as Serializable) - } else { - Log.e(TAG, "Unable to save '$obj'") - } - } - if (serializables.size != objects.size) { - val message = """ - You should make your objects Serializable or Parcelable or - override getSerializableObjects and convertSerializableArrayToObjectArray - """.trimIndent() - Log.e(TAG, message) + protected fun getSerializableObjects(): List { + val serializables = ArrayList() + for (obj in objects) { + if (obj is Serializable) { + serializables.add(obj as Serializable) + } else { + Log.e(TAG, "Unable to save '$obj'") } - return serializables } + if (serializables.size != objects.size) { + val message = """ + You should make your objects Serializable or Parcelable or + override getSerializableObjects and convertSerializableArrayToObjectArray + """.trimIndent() + Log.e(TAG, message) + } + return serializables + } @Suppress("MemberVisibilityCanBePrivate") protected fun convertSerializableObjectsToTypedObjects(s: List<*>?): List? { @@ -1301,7 +1295,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } else { //Fallback on Serializable state.parcelableClassName = SavedState.SERIALIZABLE_PLACEHOLDER - state.baseObjects = serializableObjects + state.baseObjects = getSerializableObjects() } state.tokenizer = tokenizer @@ -1340,8 +1334,10 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } //TODO: change this to keep object spans in the correct locations based on ranges. - for (obj in objects!!) { - addObjectSync(obj) + if (objects != null) { + for (obj in objects) { + addObjectSync(obj) + } } // Collapse the view if necessary @@ -1361,7 +1357,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On var performBestGuess = false var preventFreeFormText = false var tokenClickStyle: TokenClickStyle? = null - var parcelableClassName: String? = null + var parcelableClassName: String = SERIALIZABLE_PLACEHOLDER var baseObjects: List<*>? = null var tokenizerClassName: String? = null var tokenizer: Tokenizer? = null @@ -1372,12 +1368,12 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On performBestGuess = parcel.readInt() != 0 preventFreeFormText = parcel.readInt() != 0 tokenClickStyle = TokenClickStyle.values()[parcel.readInt()] - parcelableClassName = parcel.readString() + parcelableClassName = parcel.readString() ?: SERIALIZABLE_PLACEHOLDER baseObjects = if (SERIALIZABLE_PLACEHOLDER == parcelableClassName) { parcel.readSerializable() as ArrayList<*> } else { try { - val loader = Class.forName(parcelableClassName!!).classLoader + val loader = Class.forName(parcelableClassName).classLoader parcel.readArrayList(loader) } catch (ex: ClassNotFoundException) { //This should really never happen, class had to be available to get here @@ -1402,7 +1398,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On out.writeInt(if (allowCollapse) 1 else 0) out.writeInt(if (performBestGuess) 1 else 0) out.writeInt(if (preventFreeFormText) 1 else 0) - out.writeInt(tokenClickStyle!!.ordinal) + out.writeInt((tokenClickStyle ?: TokenClickStyle.None).ordinal) if (SERIALIZABLE_PLACEHOLDER == parcelableClassName) { out.writeString(SERIALIZABLE_PLACEHOLDER) out.writeSerializable(baseObjects as Serializable?) @@ -1515,7 +1511,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On if (!canDeleteSelection(fixedBeforeLength)) return false //Shouldn't be able to delete prefix, so don't do anything - if (selectionStart <= prefix!!.length) { + if (selectionStart <= prefix?.length ?: 0) { fixedBeforeLength = 0 return deleteSelectedObject() || super.deleteSurroundingText( fixedBeforeLength, From 9b605fbd2b53f6ac7326cba01ea3ed860d15c9d2 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Tue, 24 Aug 2021 12:05:49 -0400 Subject: [PATCH 21/33] Update version to 4.0.0-beta03/27 --- gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 5a8c3cf3..03506843 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,8 +12,8 @@ # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -VERSION_NAME=4.0.0-beta02 -VERSION_CODE=26 +VERSION_NAME=4.0.0-beta03 +VERSION_CODE=27 GROUP=com.splitwise ANDROID_BUILD_MIN_SDK_VERSION=14 ANDROID_BUILD_TARGET_SDK_VERSION=29 From 6fcea51c6895cdd49672b15693f4a1b18f2de261 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Tue, 24 Aug 2021 14:09:18 -0400 Subject: [PATCH 22/33] Add open to expose methods for subclasses to override --- .../TokenCompleteTextView.kt | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt index f3d3f391..0bed653f 100644 --- a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt +++ b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt @@ -100,7 +100,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On /** * Add the TextChangedListeners */ - protected fun addListeners() { + protected open fun addListeners() { val text = text if (text != null) { text.setSpan(spanWatcher, 0, text.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) @@ -111,7 +111,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On /** * Remove the TextChangedListeners */ - protected fun removeListeners() { + protected open fun removeListeners() { val text = text if (text != null) { val spanWatchers = text.getSpans(0, text.length, TokenSpanWatcher::class.java) @@ -247,7 +247,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On * @param token the token to check * @return false if the token should not be removed, true if it's ok to remove it. */ - fun isTokenRemovable(@Suppress("unused_parameter") token: T): Boolean { + open fun isTokenRemovable(@Suppress("unused_parameter") token: T): Boolean { return true } @@ -395,7 +395,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On * @return custom string for accessibility */ @Suppress("MemberVisibilityCanBePrivate") - val textForAccessibility: CharSequence + open val textForAccessibility: CharSequence get() { if (objects.isEmpty()) { return text @@ -515,11 +515,11 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On * @return the string representation of the token. Defaults to [Object.toString] */ @Suppress("MemberVisibilityCanBePrivate") - protected fun tokenToString(token: T): CharSequence { + protected open fun tokenToString(token: T): CharSequence { return token.toString() } - protected fun currentCompletionText(): String { + protected open fun currentCompletionText(): String { if (hintVisible) return "" //Can't have any text if the hint is visible val editable = text val currentRange = currentCandidateTokenRange @@ -529,7 +529,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } @Suppress("MemberVisibilityCanBePrivate") - protected fun maxTextWidth(): Float { + protected open fun maxTextWidth(): Float { return (width - paddingLeft - paddingRight).toFloat() } @@ -725,7 +725,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On * * @param hasFocus boolean indicating whether we have the focus or not. */ - fun performCollapse(hasFocus: Boolean) { + open fun performCollapse(hasFocus: Boolean) { internalEditInProgress = true if (!hasFocus) { // Display +x thingy/ellipse if appropriate @@ -807,7 +807,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } @Suppress("MemberVisibilityCanBePrivate") - protected fun buildSpanForObject(obj: T?): TokenImageSpan? { + protected open fun buildSpanForObject(obj: T?): TokenImageSpan? { if (obj == null) { return null } @@ -1229,7 +1229,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } @Suppress("MemberVisibilityCanBePrivate") - protected fun getSerializableObjects(): List { + protected open fun getSerializableObjects(): List { val serializables = ArrayList() for (obj in objects) { if (obj is Serializable) { @@ -1249,7 +1249,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } @Suppress("MemberVisibilityCanBePrivate") - protected fun convertSerializableObjectsToTypedObjects(s: List<*>?): List? { + protected open fun convertSerializableObjectsToTypedObjects(s: List<*>?): List? { @Suppress("unchecked_cast") return s as List? } From b1fd4fea7a4986d3d13020896e9634760fc74125 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Tue, 24 Aug 2021 14:09:44 -0400 Subject: [PATCH 23/33] Update version to 4.0.0-beta04/28 --- gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 03506843..938c5e6f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,8 +12,8 @@ # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -VERSION_NAME=4.0.0-beta03 -VERSION_CODE=27 +VERSION_NAME=4.0.0-beta04 +VERSION_CODE=28 GROUP=com.splitwise ANDROID_BUILD_MIN_SDK_VERSION=14 ANDROID_BUILD_TARGET_SDK_VERSION=29 From d6312a2b803c9827742fecbba5f525451b561934 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Fri, 22 Oct 2021 15:51:16 +0200 Subject: [PATCH 24/33] Upgrade gradle Fix libs versions Fix compilation --- build.gradle | 2 +- example/build.gradle | 4 ++-- gradle.properties | 3 +-- gradle/wrapper/gradle-wrapper.properties | 2 +- library/build.gradle | 8 +++----- .../java/com/tokenautocomplete/TokenCompleteTextView.kt | 6 +++--- 6 files changed, 11 insertions(+), 14 deletions(-) diff --git a/build.gradle b/build.gradle index a3c979e0..404ac545 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ buildscript { jcenter() } dependencies { - classpath "com.android.tools.build:gradle:4.0.1" + classpath 'com.android.tools.build:gradle:7.0.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/example/build.gradle b/example/build.gradle index 20f6d6e8..bb1fecf4 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -6,8 +6,8 @@ dependencies { androidTestImplementation "androidx.test.espresso:espresso-intents:3.2.0" androidTestImplementation "androidx.test.espresso:espresso-core:3.2.0" - implementation "androidx.annotation:annotation:$ANDROIDX_VERSION" - implementation "androidx.appcompat:appcompat:$ANDROIDX_VERSION" + implementation "androidx.annotation:annotation:1.2.0" + implementation "androidx.appcompat:appcompat:1.3.1" implementation project(":library") } diff --git a/gradle.properties b/gradle.properties index 938c5e6f..e4d0f07d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,8 +18,7 @@ GROUP=com.splitwise ANDROID_BUILD_MIN_SDK_VERSION=14 ANDROID_BUILD_TARGET_SDK_VERSION=29 ANDROID_BUILD_SDK_VERSION=29 -ANDROIDX_VERSION=1.1.0 -KOTLIN_VERSION=1.5.21 +KOTLIN_VERSION=1.5.31 POM_DESCRIPTION=Android Token AutoComplete EditText POM_URL=https://github.com/splitwise/TokenAutoComplete POM_SCM_URL=https://github.com/splitwise/TokenAutoComplete diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 264801a5..0ab5bc6e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip diff --git a/library/build.gradle b/library/build.gradle index 94841746..3878f2a6 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -16,8 +16,6 @@ android { defaultConfig { minSdkVersion Integer.parseInt(project.ANDROID_BUILD_MIN_SDK_VERSION) targetSdkVersion Integer.parseInt(project.ANDROID_BUILD_TARGET_SDK_VERSION) - versionCode Integer.parseInt(project.VERSION_CODE) - versionName project.VERSION_NAME } buildTypes { @@ -39,9 +37,9 @@ android { dependencies { testImplementation("junit:junit:4.13") - implementation("androidx.annotation:annotation:$ANDROIDX_VERSION") - implementation("androidx.appcompat:appcompat:$ANDROIDX_VERSION") - implementation "androidx.core:core-ktx:+" + implementation "androidx.annotation:annotation:1.2.0" + implementation "androidx.appcompat:appcompat:1.3.1" + implementation "androidx.core:core-ktx:1.6.0" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" } diff --git a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt index 0bed653f..3112b51c 100644 --- a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt +++ b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt @@ -190,15 +190,15 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On initialized = true } - constructor(context: Context?) : super(context) { + constructor(context: Context) : super(context) { init() } - constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) { + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { init() } - constructor(context: Context?, attrs: AttributeSet?, defStyle: Int) : super( + constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super( context, attrs, defStyle From 47148fa4fc21ea6c674c002c3d8aa466b39c5178 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Fri, 22 Oct 2021 16:00:58 +0200 Subject: [PATCH 25/33] Fix null pointer exception #421 --- .../java/com/tokenautocomplete/TokenCompleteTextView.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt index 3112b51c..830eeba4 100644 --- a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt +++ b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt @@ -605,7 +605,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On imm.hideSoftInputFromWindow(windowToken, 0) } - override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { val handled = super.onKeyUp(keyCode, event) if (shouldFocusNext) { shouldFocusNext = false @@ -614,10 +614,10 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On return handled } - override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { var handled = false when (keyCode) { - KeyEvent.KEYCODE_TAB, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_DPAD_CENTER -> if (event.hasNoModifiers()) { + KeyEvent.KEYCODE_TAB, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_DPAD_CENTER -> if (event?.hasNoModifiers() == true) { shouldFocusNext = true handled = true } @@ -642,7 +642,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On return false } - override fun onEditorAction(view: TextView, action: Int, keyEvent: KeyEvent): Boolean { + override fun onEditorAction(view: TextView, action: Int, keyEvent: KeyEvent?): Boolean { if (action == EditorInfo.IME_ACTION_DONE) { handleDone() return true From 8540a879d5e1e46c16a56568ce7a1133c5d9564c Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Thu, 27 Jan 2022 16:10:30 +0100 Subject: [PATCH 26/33] Fix small warnings --- .../src/main/java/com/tokenautocomplete/CharacterTokenizer.kt | 1 - .../src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/library/src/main/java/com/tokenautocomplete/CharacterTokenizer.kt b/library/src/main/java/com/tokenautocomplete/CharacterTokenizer.kt index 549b3088..7c41eeda 100644 --- a/library/src/main/java/com/tokenautocomplete/CharacterTokenizer.kt +++ b/library/src/main/java/com/tokenautocomplete/CharacterTokenizer.kt @@ -1,7 +1,6 @@ package com.tokenautocomplete import android.annotation.SuppressLint -import android.os.Parcelable import android.text.SpannableString import android.text.Spanned import android.text.TextUtils diff --git a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt index 830eeba4..45d9ef86 100644 --- a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt +++ b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt @@ -1142,6 +1142,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On //Make sure the selection is not in the middle of the span setSelection(text.getSpanEnd(this)) } + else -> {} } } } From 6053e41682d1fd28e21074af8ae753f47d9a522e Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Tue, 2 Aug 2022 16:01:37 -0400 Subject: [PATCH 27/33] Use correct Apache 2.0 license string Close #423. --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index e4d0f07d..7708cae3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -24,7 +24,7 @@ POM_URL=https://github.com/splitwise/TokenAutoComplete POM_SCM_URL=https://github.com/splitwise/TokenAutoComplete POM_SCM_CONNECTION=scm:git@github.com:splitwise/TokenAutoComplete.git POM_SCM_DEV_CONNECTION=scm:git@github.com:splitwise/TokenAutoComplete.git -POM_LICENCE_NAME=Apache v2 +POM_LICENCE_NAME=Apache-2.0 POM_LICENCE_URL=https://github.com/splitwise/TokenAutoComplete/blob/master/LICENSE POM_LICENCE_DIST=repo POM_DEVELOPER_ID=mgod From 1a862d94f57d30714732c5b5ca0537af4c7d91e7 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Mon, 12 Dec 2022 11:34:29 -0500 Subject: [PATCH 28/33] Fix crash on setting selections after nonexistant prefix --- .../main/java/com/tokenautocomplete/TokenCompleteTextView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt index 45d9ef86..2acfb44a 100644 --- a/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt +++ b/library/src/main/java/com/tokenautocomplete/TokenCompleteTextView.kt @@ -694,7 +694,7 @@ abstract class TokenCompleteTextView : AppCompatAutoCompleteTextView, On } if (selectionStart < prefix?.length ?: 0 || selectionEnd < prefix?.length ?: 0) { //Don't let users select the prefix - setSelection(prefix?.length ?: 0) + setSelection((prefix?.length ?: 0).coerceAtMost(text?.length ?: 0)) } else { val text = text if (text != null) { From 834fd8a65673eaa77e0ddac42ee16b2353452643 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Tue, 13 Dec 2022 14:37:10 -0500 Subject: [PATCH 29/33] Update target versions and build tools --- .github/workflows/android.yml | 24 +++++++++++++++++----- build.gradle | 5 ++--- example/build.gradle | 16 +++++++-------- example/src/main/AndroidManifest.xml | 6 ++++-- gradle.properties | 7 ++++--- gradle/wrapper/gradle-wrapper.properties | 2 +- library/build.gradle | 26 ++++++++++++++---------- 7 files changed, 53 insertions(+), 33 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 29653361..c2ef8914 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -12,14 +12,27 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - api-version: [23, 24, 25, 26, 27, 28, 29, 30] + device-split: [ + {model: "NexusLowRes", version: 23}, + {model: "NexusLowRes", version: 24}, + {model: "NexusLowRes", version: 25}, + {model: "NexusLowRes", version: 26}, + {model: "NexusLowRes", version: 27}, + {model: "NexusLowRes", version: 28}, + {model: "NexusLowRes", version: 29}, + {model: "NexusLowRes", version: 30}, + + {model: "oriole", version: 31}, + {model: "oriole", version: 32}, + {model: "oriole", version: 33} + ] steps: - uses: actions/checkout@v2 - - name: set up JDK 1.8 + - name: set up JDK 11 uses: actions/setup-java@v1 with: - java-version: 1.8 + java-version: 11 - name: Build with Gradle run: ./gradlew build - name: Run unit tests @@ -35,6 +48,7 @@ jobs: gcloud auth activate-service-account --key-file=${HOME}/gcloud-service-key.json gcloud --quiet config set project $GOOGLE_PROJECT_ID - name: Run Instrumented Tests with Firebase Test Lab - run: gcloud firebase test android run --type instrumentation --app example/build/outputs/apk/debug/example-debug.apk --test example/build/outputs/apk/androidTest/debug/example-debug-androidTest.apk --device model=NexusLowRes,version=$API_VERSION,locale=en,orientation=portrait --timeout 30m + run: gcloud firebase test android run --type instrumentation --app example/build/outputs/apk/debug/example-debug.apk --test example/build/outputs/apk/androidTest/debug/example-debug-androidTest.apk --device model=$DEVICE,version=$API_VERSION,locale=en,orientation=portrait --timeout 30m env: - API_VERSION: ${{ matrix.api-version }} + API_VERSION: ${{ matrix.device-split.version }} + DEVICE: ${{ matrix.device-split.model }} diff --git a/build.gradle b/build.gradle index 404ac545..4b08e731 100644 --- a/build.gradle +++ b/build.gradle @@ -5,17 +5,16 @@ buildscript { repositories { mavenCentral() google() - jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:7.0.3' + classpath 'com.android.tools.build:gradle:7.3.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } allprojects { repositories { - jcenter() + mavenCentral() google() } } diff --git a/example/build.gradle b/example/build.gradle index bb1fecf4..47e7fce9 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -1,13 +1,13 @@ apply plugin: 'com.android.application' dependencies { - androidTestImplementation "androidx.test:runner:1.2.0" - androidTestImplementation "androidx.test.ext:junit:1.1.1" - androidTestImplementation "androidx.test.espresso:espresso-intents:3.2.0" - androidTestImplementation "androidx.test.espresso:espresso-core:3.2.0" + androidTestImplementation "androidx.test:runner:1.5.1" + androidTestImplementation "androidx.test.ext:junit:1.1.4" + androidTestImplementation "androidx.test.espresso:espresso-intents:3.5.0" + androidTestImplementation "androidx.test.espresso:espresso-core:3.5.0" - implementation "androidx.annotation:annotation:1.2.0" - implementation "androidx.appcompat:appcompat:1.3.1" + implementation "androidx.annotation:annotation:1.5.0" + implementation "androidx.appcompat:appcompat:1.5.1" implementation project(":library") } @@ -28,7 +28,7 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 } } diff --git a/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml index 1db943cf..f0185066 100644 --- a/example/src/main/AndroidManifest.xml +++ b/example/src/main/AndroidManifest.xml @@ -9,7 +9,7 @@ android:theme="@style/AppTheme" > + android:exported="true"> @@ -17,7 +17,9 @@ - + diff --git a/gradle.properties b/gradle.properties index 7708cae3..2e8ede82 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,9 +16,9 @@ VERSION_NAME=4.0.0-beta04 VERSION_CODE=28 GROUP=com.splitwise ANDROID_BUILD_MIN_SDK_VERSION=14 -ANDROID_BUILD_TARGET_SDK_VERSION=29 -ANDROID_BUILD_SDK_VERSION=29 -KOTLIN_VERSION=1.5.31 +ANDROID_BUILD_TARGET_SDK_VERSION=33 +ANDROID_BUILD_SDK_VERSION=33 +KOTLIN_VERSION=1.7.20 POM_DESCRIPTION=Android Token AutoComplete EditText POM_URL=https://github.com/splitwise/TokenAutoComplete POM_SCM_URL=https://github.com/splitwise/TokenAutoComplete @@ -31,3 +31,4 @@ POM_DEVELOPER_ID=mgod POM_DEVELOPER_NAME=Marshall Weir android.useAndroidX=true android.enableJetifier=false +org.gradle.jvmargs=-Xmx4096m "-XX:MaxMetaspaceSize=512m" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0ab5bc6e..4b41815b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip diff --git a/library/build.gradle b/library/build.gradle index 3878f2a6..2c0557c6 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -1,10 +1,10 @@ plugins { - id("com.android.library") - id("maven-publish") - id("signing") + id 'com.android.library' + id 'maven-publish' + id 'signing' + id 'kotlin-android' + id 'kotlin-parcelize' } -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-parcelize' android { compileSdkVersion Integer.parseInt(project.ANDROID_BUILD_SDK_VERSION) @@ -25,8 +25,12 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget=JavaVersion.VERSION_11.toString() } testOptions { @@ -35,11 +39,11 @@ android { } dependencies { - testImplementation("junit:junit:4.13") + testImplementation("junit:junit:4.13.2") - implementation "androidx.annotation:annotation:1.2.0" - implementation "androidx.appcompat:appcompat:1.3.1" - implementation "androidx.core:core-ktx:1.6.0" + implementation "androidx.annotation:annotation:1.5.0" + implementation "androidx.appcompat:appcompat:1.5.1" + implementation "androidx.core:core-ktx:1.9.0" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" } From 94d2f6f5bcf44c4dc2e7a87cf0f50bc487aaab75 Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Wed, 14 Dec 2022 16:20:25 -0500 Subject: [PATCH 30/33] Update version to 4.0.0-beta05/29 --- gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 2e8ede82..973d43e4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,8 +12,8 @@ # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -VERSION_NAME=4.0.0-beta04 -VERSION_CODE=28 +VERSION_NAME=4.0.0-beta05 +VERSION_CODE=29 GROUP=com.splitwise ANDROID_BUILD_MIN_SDK_VERSION=14 ANDROID_BUILD_TARGET_SDK_VERSION=33 From 3913191c0325e75511947a388550f4efbf9b1ccf Mon Sep 17 00:00:00 2001 From: Goooler Date: Thu, 10 Aug 2023 18:43:10 +0800 Subject: [PATCH 31/33] Add badges in README --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 2ab5be67..0cd36913 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +[![Android CI](https://github.com/splitwise/TokenAutoComplete/actions/workflows/android.yml/badge.svg)](https://github.com/splitwise/TokenAutoComplete/actions/workflows/android.yml) +[![License](https://img.shields.io/github/license/splitwise/TokenAutoComplete.svg)](LICENSE) +[![Maven Central](https://img.shields.io/maven-central/v/com.splitwise/tokenautocomplete.svg)](https://search.maven.org/artifact/com.splitwise/tokenautocomplete) + + ### Version 3.0 The `3.0.1` version is now available! This should resolve a number of text handling issues and lay the groundwork for better support of mixed text and token input. If you're still on `2.*`, you can find the docs for `2.0.8` [here](https://github.com/splitwise/TokenAutoComplete/tree/2.0.8). From 5687d36bb9fa5ba49ee56b109d67a984c1dfcff3 Mon Sep 17 00:00:00 2001 From: Goooler Date: Thu, 10 Aug 2023 18:44:10 +0800 Subject: [PATCH 32/33] Polish setup step in README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0cd36913..ccfa8a2b 100644 --- a/README.md +++ b/README.md @@ -61,13 +61,13 @@ Setup ===== ### Gradle -``` +```groovy dependencies { - compile "com.splitwise:tokenautocomplete:3.0.1@aar" + implementation "com.splitwise:tokenautocomplete:3.0.1@aar" } ``` ### Maven -``` +```xml com.splitwise tokenautocomplete From 6121ded8404b2e826b50b61025cde62979fd215e Mon Sep 17 00:00:00 2001 From: Marshall Weir Date: Mon, 18 Mar 2024 12:12:05 -0400 Subject: [PATCH 33/33] Add Google Play Data Safety guidance --- DATA_SAFETY.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 DATA_SAFETY.md diff --git a/DATA_SAFETY.md b/DATA_SAFETY.md new file mode 100644 index 00000000..20fd61f0 --- /dev/null +++ b/DATA_SAFETY.md @@ -0,0 +1,3 @@ +## Google Play Data Safety guidance + +TokenAutoComplete does not collect any user data.