├── .gitignore
├── LICENSE.txt
├── README.md
├── app
├── build.gradle.kts
├── google-services.json
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── java
│ └── com
│ │ └── crossbowffs
│ │ └── usticker
│ │ ├── FailableAsyncTask.kt
│ │ ├── FirebaseIndexUpdater.kt
│ │ ├── Klog.kt
│ │ ├── MainActivity.kt
│ │ ├── Prefs.kt
│ │ ├── Result.kt
│ │ ├── SettingsFragment.kt
│ │ ├── Sticker.kt
│ │ ├── StickerPack.kt
│ │ ├── StickerProvider.kt
│ │ ├── StickerScanner.kt
│ │ ├── StickerSortOrder.kt
│ │ └── TooManyStickersException.kt
│ └── res
│ ├── drawable
│ └── ic_launcher_foreground.xml
│ ├── layout
│ └── activity_main.xml
│ ├── mipmap-anydpi-v26
│ └── ic_launcher.xml
│ ├── mipmap-hdpi
│ └── ic_launcher.png
│ ├── mipmap-mdpi
│ └── ic_launcher.png
│ ├── mipmap-xhdpi
│ └── ic_launcher.png
│ ├── mipmap-xxhdpi
│ └── ic_launcher.png
│ ├── mipmap-xxxhdpi
│ └── ic_launcher.png
│ ├── values
│ ├── arrays.xml
│ ├── ic_launcher_background.xml
│ └── strings.xml
│ └── xml
│ └── settings.xml
├── build.gradle.kts
├── gradle.properties
├── settings.gradle.kts
└── web
├── feature_graphic_1024x500.pdn
├── feature_graphic_1024x500.png
└── ic_launcher-web.png
/.gitignore:
--------------------------------------------------------------------------------
1 | # built application files
2 | *.apk
3 | *.ap_
4 |
5 | # files for the dex VM
6 | *.dex
7 |
8 | # Java class files
9 | *.class
10 |
11 | # generated files
12 | bin/
13 | gen/
14 | build/
15 | release/
16 |
17 | # libraries
18 | libs/
19 |
20 | # Local configuration file (sdk path, etc)
21 | local.properties
22 |
23 | # Android Studio
24 | .idea/
25 | *.iml
26 |
27 | # Gradle
28 | .gradle/
29 | gradle/
30 | gradlew
31 | gradlew.bat
32 |
33 | # Mac
34 | .DS_Store
35 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018 Andrew Sun (@crossbowffs)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # uSticker
2 |
3 | **NOTICE**: uSticker is incompatible with Gboard 10.2 and above. If you
4 | want to continue using uSticker for the time being until an alternative
5 | is available, please downgrade Gboard to 10.1
6 | ([APKMirror link](https://www.apkmirror.com/apk/google-inc/gboard/gboard-10-1-04-342850159-release/)).
7 | See [#49](https://github.com/apsun/uSticker/issues/49) for more info.
8 |
9 | uSticker is a local-storage sticker provider for Gboard. Want to save all
10 | your stickers locally, but don't want to search for them in your gallery?
11 | uSticker lets you access them directly inside Gboard!
12 |
13 | Note that this is strictly a personal project, and the feature set will be
14 | determined by my own needs. I make this app public only in the hopes
15 | that you share my use-case and find it useful; please do not submit
16 | feature requests.
17 |
18 | ## Announcement
19 |
20 | Shameless self promotion: Love using uSticker, but switched to an iPhone?
21 | Try [Stickerboard](https://github.com/apsun/Stickerboard)! It's got the
22 | same great feature set, for the same great price of $0.00!
23 |
24 | uSticker will continue to be maintained for the foreseeable future, but
25 | don't expect me to spend too much time investigating issues. If you submit
26 | an issue, please search to see if it's one of the dozens of known Gboard
27 | bugs first.
28 |
29 | ## Usage
30 |
31 | 1. Save stickers to your phone's storage
32 | 2. Run uSticker and press 'import stickers'
33 | 3. ???
34 | 4. Profit!
35 |
36 | ## Known issues
37 |
38 | If you get the message "Something went wrong" when trying to send a sticker
39 | in Gboard, try opening the uSticker app (you don't need to re-import, just
40 | opening the app should be sufficient). I believe this has to do with
41 | something about content providers and reboots. Android 11 has been a
42 | complete dumpster fire.
43 |
44 | If you get any other errors, please submit a bug report and attach a logcat
45 | dump.
46 |
47 | ## FAQ
48 |
49 | ### Why does this even exist?
50 |
51 | I'm a sticker addict, and I recently switched from WeChat to Signal. The
52 | only thing I miss is the convenience of sending stickers, hence this app.
53 |
54 | ### What's the difference between this and other sticker packs?
55 |
56 | uSticker lets you use your *own* stickers, not some pre-defined set
57 | of images. Obviously, if you don't have any stickers, this app won't be very
58 | useful.
59 |
60 | ### Will you add support for (insert app here)?
61 |
62 | If it supports Gboard stickers, then it already works. Otherwise, no. I will
63 | not go out of my way to support an app I do not use.
64 |
65 | ### How can I support this project?
66 |
67 | By using it :-)
68 |
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.application")
3 | id("com.google.gms.google-services")
4 | id("kotlin-android")
5 | }
6 |
7 | dependencies {
8 | implementation("org.jetbrains.kotlin:kotlin-stdlib:1.4.30")
9 | implementation("com.google.firebase:firebase-appindexing:19.2.0")
10 | }
11 |
12 | android {
13 | compileSdkVersion(30)
14 |
15 | defaultConfig {
16 | minSdkVersion(21)
17 | targetSdkVersion(30)
18 | versionCode(16)
19 | versionName("1.6.0")
20 | buildConfigField("String", "LOG_TAG", "\"uSticker\"")
21 | }
22 |
23 | buildTypes {
24 | getByName("debug") {
25 | buildConfigField("int", "LOG_LEVEL", "2")
26 | }
27 |
28 | getByName("release") {
29 | postprocessing {
30 | isRemoveUnusedCode = true
31 | isRemoveUnusedResources = true
32 | isObfuscate = false
33 | isOptimizeCode = true
34 | }
35 | buildConfigField("int", "LOG_LEVEL", "4")
36 | }
37 | }
38 |
39 | lintOptions {
40 | isAbortOnError = false
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/app/google-services.json:
--------------------------------------------------------------------------------
1 | {
2 | "project_info": {
3 | "project_number": "842128948910",
4 | "firebase_url": "https://usticker-ea082.firebaseio.com",
5 | "project_id": "usticker-ea082",
6 | "storage_bucket": "usticker-ea082.appspot.com"
7 | },
8 | "client": [
9 | {
10 | "client_info": {
11 | "mobilesdk_app_id": "1:842128948910:android:05c896cb4bf194ae",
12 | "android_client_info": {
13 | "package_name": "com.crossbowffs.usticker"
14 | }
15 | },
16 | "oauth_client": [
17 | {
18 | "client_id": "842128948910-uonulugg16d8hst251eammqquq533jde.apps.googleusercontent.com",
19 | "client_type": 3
20 | }
21 | ],
22 | "api_key": [
23 | {
24 | "current_key": "AIzaSyDr0UEWU4QxlNwQuno6wO6iGONCpTgdCC0"
25 | }
26 | ],
27 | "services": {
28 | "analytics_service": {
29 | "status": 1
30 | },
31 | "appinvite_service": {
32 | "status": 1,
33 | "other_platform_oauth_client": []
34 | },
35 | "ads_service": {
36 | "status": 2
37 | }
38 | }
39 | }
40 | ],
41 | "configuration_version": "1"
42 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
27 |
30 |
33 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/app/src/main/java/com/crossbowffs/usticker/FailableAsyncTask.kt:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.usticker
2 |
3 | import android.os.AsyncTask
4 |
5 | /**
6 | * Represents an AsyncTask with a background step that may throw an exception.
7 | * The result of the background step is wrapped in a Result and passed to the
8 | * given callback on the main thread.
9 | */
10 | abstract class FailableAsyncTask : AsyncTask>() {
11 | private var callback: ((Result) -> Unit)? = null
12 |
13 | /**
14 | * Override this, not doInBackground, to perform the background step.
15 | */
16 | abstract fun run(arg: TParam): TResult
17 |
18 | /**
19 | * Executes the AsyncTask with a single parameter and a callback to
20 | * be called on the main thread when the AsyncTask completes.
21 | */
22 | fun executeWithCallback(arg: TParam, callback: (Result) -> Unit) {
23 | this.callback = callback
24 | execute(arg)
25 | }
26 |
27 | final override fun doInBackground(vararg args: TParam): Result {
28 | return try {
29 | Result.Ok(run(args[0]))
30 | } catch (e: Exception) {
31 | Result.Err(e)
32 | }
33 | }
34 |
35 | final override fun onPostExecute(result: Result) {
36 | val callback = this.callback
37 | if (callback != null) {
38 | callback(result)
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/main/java/com/crossbowffs/usticker/FirebaseIndexUpdater.kt:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.usticker
2 |
3 | import com.google.firebase.appindexing.FirebaseAppIndex
4 | import com.google.firebase.appindexing.Indexable
5 | import com.google.firebase.appindexing.builders.Indexables
6 |
7 | /**
8 | * Updates the Firebase index with a new list of sticker packs.
9 | */
10 | class FirebaseIndexUpdater {
11 | companion object {
12 | private val NON_ALPHA_NUM_CHAR_REGEX = Regex("[^\\p{N}\\p{L}]")
13 | private val ALPHA_OR_NUM_REGEX = Regex("\\p{N}+|\\p{L}+")
14 | private const val MAX_STICKERS_PER_PACK = 100
15 | }
16 |
17 | private val fbIndex = FirebaseAppIndex.getInstance()
18 |
19 | /**
20 | * Generates a list of keywords given a filename.
21 | * Currently, this splits on all non-alphanumeric
22 | * characters and alpha-numeric boundaries.
23 | */
24 | private fun getKeywords(fileName: String): List {
25 | // First, split on any non-alphanumeric characters
26 | val nonSplitAlphaNum = fileName
27 | .split(NON_ALPHA_NUM_CHAR_REGEX)
28 | .filter(String::isNotBlank)
29 |
30 | // Then, split patterns like abc123def to [abc, 123, def]
31 | return nonSplitAlphaNum.flatMap {
32 | ALPHA_OR_NUM_REGEX
33 | .findAll(it)
34 | .map(MatchResult::value)
35 | .toList()
36 | }
37 | }
38 |
39 | /**
40 | * Trims off the extension from a filename (e.g. foo.bar -> foo).
41 | */
42 | private fun getNameWithoutExtension(fileName: String): String {
43 | return fileName.substringBeforeLast('.')
44 | }
45 |
46 | /**
47 | * Converts a list of sticker packs into a list of
48 | * Indexable objects to pass to the Firebase indexer.
49 | */
50 | private fun stickerPacksToIndexables(stickerPacks: List): List {
51 | return stickerPacks.flatMap { stickerPack ->
52 | val packPath = if (stickerPack.path == "") { emptyList() } else { stickerPack.path.split('/') }
53 | val packName = packPath.lastOrNull() ?: "Stickers"
54 | val packUri = stickerPack.getFirebaseUri()
55 | val packKeywords = packPath.flatMap(this::getKeywords)
56 | val stickerCount = stickerPack.stickers.size
57 | if (stickerCount > MAX_STICKERS_PER_PACK) {
58 | throw TooManyStickersException(stickerCount, MAX_STICKERS_PER_PACK, stickerPack.path)
59 | }
60 | Klog.i("Importing $packName with $stickerCount sticker(s)")
61 |
62 | // Create all stickers in the pack
63 | val stickers = stickerPack.stickers.map { sticker ->
64 | val keywords = packKeywords + getKeywords(getNameWithoutExtension(sticker.name))
65 | Indexables.stickerBuilder()
66 | .setName(sticker.name)
67 | .setImage(sticker.getFileUri().toString())
68 | .setKeywords(*keywords.toTypedArray())
69 | .setUrl(sticker.getFirebaseUri(packUri).toString())
70 | }
71 |
72 | // Then create the sticker pack
73 | val pack = Indexables.stickerPackBuilder()
74 | .setName(packName)
75 | .setImage(stickerPack.stickers[0].getFileUri().toString())
76 | .setUrl(packUri.toString())
77 | .setHasSticker(*stickers.toTypedArray())
78 | .setMetadata(Indexable.Metadata.Builder().setWorksOffline(true))
79 | .build()
80 |
81 | // Finally add the sticker pack attribute to the stickers
82 | stickers.map { it
83 | .setIsPartOf(Indexables.stickerPackBuilder()
84 | .setName(packName)
85 | .setUrl(packUri.toString()))
86 | .setMetadata(Indexable.Metadata.Builder().setWorksOffline(true))
87 | .build()
88 | } + listOf(pack)
89 | }
90 | }
91 |
92 | /**
93 | * Performs batched updates of the Firebase sticker index.
94 | * Calls the provided callback on completion with the number
95 | * of sticker files imported.
96 | */
97 | private fun addIndexables(
98 | indexableList: List,
99 | offset: Int,
100 | callback: (Result) -> Unit)
101 | {
102 | val step = Math.min(indexableList.size - offset, 250)
103 | if (step > 0) {
104 | fbIndex.update(*indexableList.subList(offset, offset + step).toTypedArray())
105 | .addOnSuccessListener { addIndexables(indexableList, offset + step, callback) }
106 | .addOnFailureListener { callback(Result.Err(it)) }
107 | } else {
108 | callback(Result.Ok(Unit))
109 | }
110 | }
111 |
112 | /**
113 | * Replaces all indexed stickers with the given sticker pack list.
114 | */
115 | fun executeWithCallback(stickerPacks: List, callback: (Result) -> Unit) {
116 | val indexables = try {
117 | stickerPacksToIndexables(stickerPacks)
118 | } catch (e: Exception) {
119 | callback(Result.Err(e))
120 | return
121 | }
122 |
123 | fbIndex.removeAll()
124 | .addOnSuccessListener { addIndexables(indexables, 0, callback) }
125 | .addOnFailureListener { callback(Result.Err(it)) }
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/app/src/main/java/com/crossbowffs/usticker/Klog.kt:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.usticker
2 |
3 | import android.util.Log
4 |
5 | /**
6 | * Simple unified Android logger, Kotlin version.
7 | * Works just like the Log class, but without the tag parameter.
8 | */
9 | object Klog {
10 | private const val LOG_TAG = BuildConfig.LOG_TAG
11 | private const val LOG_LEVEL = BuildConfig.LOG_LEVEL
12 |
13 | private fun log(priority: Int, msg: String, e: Throwable? = null) {
14 | if (priority < LOG_LEVEL) {
15 | return
16 | }
17 |
18 | var message = msg
19 | if (e != null) {
20 | message += '\n' + Log.getStackTraceString(e)
21 | }
22 |
23 | Log.println(priority, LOG_TAG, message)
24 | }
25 |
26 | fun v(message: String, e: Throwable? = null) {
27 | log(Log.VERBOSE, message, e)
28 | }
29 |
30 | fun d(message: String, e: Throwable? = null) {
31 | log(Log.DEBUG, message, e)
32 | }
33 |
34 | fun i(message: String, e: Throwable? = null) {
35 | log(Log.INFO, message, e)
36 | }
37 |
38 | fun w(message: String, e: Throwable? = null) {
39 | log(Log.WARN, message, e)
40 | }
41 |
42 | fun e(message: String, e: Throwable? = null) {
43 | log(Log.ERROR, message, e)
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/app/src/main/java/com/crossbowffs/usticker/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.usticker
2 |
3 | import android.app.Activity
4 | import android.os.Bundle
5 |
6 | class MainActivity : Activity() {
7 | override fun onCreate(savedInstanceState: Bundle?) {
8 | super.onCreate(savedInstanceState)
9 | setContentView(R.layout.activity_main)
10 | getFragmentManager()
11 | .beginTransaction()
12 | .replace(R.id.content_frame, SettingsFragment())
13 | .commit()
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/app/src/main/java/com/crossbowffs/usticker/Prefs.kt:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.usticker
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.content.SharedPreferences
6 | import android.net.Uri
7 | import android.preference.PreferenceManager
8 | import java.lang.IllegalArgumentException
9 |
10 | /**
11 | * Manages app preferences, with a touch of business logic.
12 | */
13 | object Prefs {
14 | private const val PREF_STICKER_DIR_URI = "pref_sticker_dir_uri"
15 | private const val PREF_STICKER_SORT_ORDER = "pref_sticker_sort_order"
16 |
17 | private fun getSharedPrefs(context: Context): SharedPreferences {
18 | return PreferenceManager.getDefaultSharedPreferences(context)
19 | }
20 |
21 | /**
22 | * Returns the current configured sticker directory, or null if no
23 | * directory is set.
24 | */
25 | fun getStickerDir(context: Context): Uri? {
26 | val prefs = getSharedPrefs(context)
27 | val stickerDir = prefs.getString(PREF_STICKER_DIR_URI, null) ?: return null
28 | return Uri.parse(stickerDir)
29 | }
30 |
31 | /**
32 | * Sets the configured sticker directory, and persists read permissions
33 | * on the directory for future use. If a previously configured directory
34 | * exists, this also releases permissions on it.
35 | *
36 | * @throws SecurityException if takePersistableUriPermission() fails
37 | */
38 | fun setStickerDir(context: Context, stickerDir: Uri) {
39 | val resolver = context.contentResolver
40 | val origStickerDir = getStickerDir(context)
41 |
42 | // Release original URI, but only if it changed. Apparently releasing
43 | // a URI and then taking it again will fail.
44 | if (origStickerDir != null && origStickerDir != stickerDir) {
45 | try {
46 | resolver.releasePersistableUriPermission(origStickerDir, Intent.FLAG_GRANT_READ_URI_PERMISSION)
47 | } catch (e: SecurityException) {
48 | // Ignore, maybe user revoked our permission externally
49 | Klog.e("releasePersistableUriPermission() failed", e)
50 | }
51 | }
52 |
53 | val prefs = getSharedPrefs(context)
54 | try {
55 | resolver.takePersistableUriPermission(stickerDir, Intent.FLAG_GRANT_READ_URI_PERMISSION)
56 | } catch (e: SecurityException) {
57 | Klog.e("takePersistableUriPermission() failed", e)
58 | prefs.edit().remove(PREF_STICKER_DIR_URI).apply()
59 | throw e
60 | }
61 |
62 | prefs.edit().putString(PREF_STICKER_DIR_URI, stickerDir.toString()).apply()
63 | Klog.i("Set sticker dir -> $stickerDir")
64 | }
65 |
66 | /**
67 | * Returns the currently selected sticker sort order, or null if
68 | * no sorting order has been selected.
69 | */
70 | fun getStickerSortOrder(context: Context): StickerSortOrder? {
71 | val prefs = getSharedPrefs(context)
72 | val key = prefs.getString(PREF_STICKER_SORT_ORDER, null) ?: return null
73 | return try {
74 | StickerSortOrder.valueOf(key)
75 | } catch (e: IllegalArgumentException) {
76 | null
77 | }
78 | }
79 |
80 | /**
81 | * If no sticker sort order has been selected, initializes it to
82 | * the default sorting order.
83 | *
84 | * TODO: We should change the default to alphabetical sometime in the future.
85 | */
86 | fun initStickerSortOrder(context: Context) {
87 | if (getStickerSortOrder(context) == null) {
88 | val prefs = getSharedPrefs(context)
89 | prefs.edit().putString(PREF_STICKER_SORT_ORDER, StickerSortOrder.NONE.name).apply()
90 | }
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/app/src/main/java/com/crossbowffs/usticker/Result.kt:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.usticker
2 |
3 | /**
4 | * Discriminated union containing either a value or an exception.
5 | */
6 | sealed class Result {
7 | class Ok(val value: T) : Result()
8 | class Err(val err: Exception) : Result()
9 | }
10 |
--------------------------------------------------------------------------------
/app/src/main/java/com/crossbowffs/usticker/SettingsFragment.kt:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.usticker
2 |
3 | import android.app.Activity
4 | import android.app.AlertDialog
5 | import android.app.Dialog
6 | import android.app.ProgressDialog
7 | import android.content.*
8 | import android.content.pm.PackageManager
9 | import android.net.Uri
10 | import android.os.Build
11 | import android.os.Bundle
12 | import android.preference.PreferenceFragment
13 | import android.provider.DocumentsContract
14 | import android.text.Html
15 | import android.util.Log
16 | import android.widget.Toast
17 |
18 | class SettingsFragment : PreferenceFragment() {
19 | companion object {
20 | private const val REQUEST_SELECT_STICKER_DIR = 2333
21 | }
22 |
23 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
24 | super.onActivityResult(requestCode, resultCode, data)
25 |
26 | if (resultCode != Activity.RESULT_OK || data == null) {
27 | return
28 | }
29 |
30 | if (requestCode != REQUEST_SELECT_STICKER_DIR) {
31 | return
32 | }
33 |
34 | val flags = Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
35 | if (data.flags and flags != flags) {
36 | Klog.e("FLAG_GRANT_PERSISTABLE_URI_PERMISSION or FLAG_GRANT_READ_URI_PERMISSION not set")
37 | Toast.makeText(activity, R.string.failed_to_obtain_read_permissions, Toast.LENGTH_SHORT).show()
38 | return
39 | }
40 |
41 | try {
42 | Prefs.setStickerDir(activity, data.data!!)
43 | } catch (e: SecurityException) {
44 | showStacktraceDialog(e)
45 | return
46 | }
47 |
48 | importStickers()
49 | }
50 |
51 | override fun onCreate(savedInstanceState: Bundle?) {
52 | super.onCreate(savedInstanceState)
53 | Prefs.initStickerSortOrder(activity)
54 | addPreferencesFromResource(R.xml.settings)
55 |
56 | findPreference("pref_import_stickers").setOnPreferenceClickListener {
57 | importStickers()
58 | true
59 | }
60 |
61 | findPreference("pref_change_sticker_dir").setOnPreferenceClickListener {
62 | selectStickerDir()
63 | true
64 | }
65 |
66 | findPreference("pref_about_help").setOnPreferenceClickListener {
67 | showHelpDialog()
68 | true
69 | }
70 |
71 | findPreference("pref_about_github").setOnPreferenceClickListener {
72 | startGitHubActivity()
73 | true
74 | }
75 |
76 | findPreference("pref_about_version").apply {
77 | setSummary(getAppVersion())
78 | setOnPreferenceClickListener {
79 | showChangelogDialog()
80 | true
81 | }
82 | }
83 | }
84 |
85 | private fun getAppVersion(): String {
86 | return "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
87 | }
88 |
89 | private fun getGboardVersion(): String? {
90 | val pkg = try {
91 | activity.packageManager.getPackageInfo("com.google.android.inputmethod.latin", 0)
92 | } catch (e: PackageManager.NameNotFoundException) {
93 | return null
94 | }
95 | return "${pkg.versionName} (${pkg.versionCode})"
96 | }
97 |
98 | private fun getIssueTemplate(stacktrace: String?): String {
99 | val appVersion = getAppVersion()
100 | val gboardVersion = getGboardVersion() ?: "N/A"
101 | val osVersion = "${Build.VERSION.RELEASE} (API${Build.VERSION.SDK_INT})"
102 | val fmt = if (stacktrace == null) {
103 | R.string.issue_template
104 | } else {
105 | R.string.issue_template_stacktrace
106 | }
107 | return getString(fmt, appVersion, gboardVersion, osVersion, stacktrace)
108 | }
109 |
110 | private fun startBrowserActivity(url: String) {
111 | val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
112 | startActivity(intent)
113 | }
114 |
115 | private fun startGitHubActivity() {
116 | startBrowserActivity("https://github.com/apsun/uSticker")
117 | }
118 |
119 | private fun copyToClipboard(text: String) {
120 | val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
121 | val clip = ClipData.newPlainText(null, text)
122 | clipboard.setPrimaryClip(clip)
123 | }
124 |
125 | private fun reportBug(stacktrace: String?) {
126 | val template = getIssueTemplate(stacktrace)
127 | copyToClipboard(template)
128 | Toast.makeText(activity, R.string.issue_template_copied, Toast.LENGTH_SHORT).show()
129 | }
130 |
131 | private fun getHtmlString(resId: Int): CharSequence {
132 | return Html.fromHtml(getString(resId))
133 | }
134 |
135 | private fun showHelpDialog() {
136 | AlertDialog.Builder(activity)
137 | .setTitle(R.string.help)
138 | .setMessage(getHtmlString(R.string.help_text))
139 | .setPositiveButton(R.string.got_it, null)
140 | .setNeutralButton(R.string.report) { _, _ ->
141 | reportBug(null)
142 | }
143 | .show()
144 | }
145 |
146 | private fun showChangelogDialog() {
147 | AlertDialog.Builder(activity)
148 | .setTitle(R.string.changelog)
149 | .setMessage(getHtmlString(R.string.changelog_text))
150 | .setPositiveButton(R.string.close, null)
151 | .show()
152 | }
153 |
154 | private fun showTooManyStickersErrorDialog(e: TooManyStickersException) {
155 | val userPath = if (e.path == "") {
156 | getString(R.string.too_many_stickers_dir_root)
157 | } else {
158 | getString(R.string.too_many_stickers_dir_path, e.path)
159 | }
160 | val message = getString(R.string.too_many_stickers_fmt, e.limit, e.count, userPath)
161 | AlertDialog.Builder(activity)
162 | .setTitle(R.string.import_failed_title)
163 | .setMessage(message)
164 | .setPositiveButton(R.string.close, null)
165 | .show()
166 | }
167 |
168 | private fun showStacktraceDialog(e: Throwable) {
169 | val stacktrace = Log.getStackTraceString(e)
170 | AlertDialog.Builder(activity)
171 | .setTitle(R.string.import_failed_title)
172 | .setMessage(getString(R.string.import_failed_message, e))
173 | .setPositiveButton(R.string.close, null)
174 | .setNeutralButton(R.string.report) { _, _ ->
175 | reportBug(stacktrace)
176 | }
177 | .show()
178 | }
179 |
180 | private fun dismissDialog(dialog: Dialog) {
181 | // This is an ugly hack, but Android is stupid and I can't figure
182 | // out how to properly solve this so let's just do this to at least
183 | // stop getting exceptions
184 | try {
185 | dialog.dismiss()
186 | } catch (e: IllegalArgumentException) {
187 | // Don't care
188 | }
189 | }
190 |
191 | private fun onImportSuccess(dialog: Dialog, numStickers: Int) {
192 | dismissDialog(dialog)
193 | Klog.i("Successfully imported $numStickers stickers")
194 | val message = getString(R.string.import_success_toast, numStickers)
195 | Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()
196 | }
197 |
198 | private fun onImportFailed(dialog: Dialog, err: Exception) {
199 | Klog.e("Failed to import stickers", err)
200 | dismissDialog(dialog)
201 |
202 | if (err is TooManyStickersException) {
203 | showTooManyStickersErrorDialog(err)
204 | return
205 | }
206 |
207 | if (err is SecurityException ||
208 | err is IllegalStateException ||
209 | err is IllegalArgumentException)
210 | {
211 | Toast.makeText(activity, R.string.sticker_dir_invalid, Toast.LENGTH_SHORT).show()
212 | selectStickerDir()
213 | return
214 | }
215 |
216 | showStacktraceDialog(err)
217 | }
218 |
219 | private fun selectStickerDir() {
220 | val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
221 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
222 | val stickerDir = Prefs.getStickerDir(activity)
223 | if (stickerDir != null) {
224 | intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, stickerDir)
225 | }
226 | }
227 |
228 | try {
229 | startActivityForResult(intent, REQUEST_SELECT_STICKER_DIR)
230 | } catch (e: ActivityNotFoundException) {
231 | Toast.makeText(activity, R.string.cannot_find_file_browser, Toast.LENGTH_SHORT).show()
232 | }
233 | }
234 |
235 | private fun sortStickers(packs: List): List {
236 | return when (Prefs.getStickerSortOrder(activity) ?: StickerSortOrder.NONE) {
237 | StickerSortOrder.NONE -> packs
238 | StickerSortOrder.ALPHABETICAL -> packs.sortedBy { pack -> pack.path }.map { pack ->
239 | StickerPack(pack.path, pack.stickers.sortedBy { sticker -> sticker.name })
240 | }
241 | }
242 | }
243 |
244 | private fun importStickers() {
245 | val stickerDir = Prefs.getStickerDir(activity)
246 | if (stickerDir == null) {
247 | Klog.i("Sticker directory not configured")
248 | selectStickerDir()
249 | return
250 | }
251 |
252 | val dialog = ProgressDialog(activity).apply {
253 | setIndeterminate(true)
254 | setCancelable(false)
255 | setMessage(getString(R.string.importing_stickers))
256 | show()
257 | }
258 |
259 | StickerScanner(activity.contentResolver).executeWithCallback(stickerDir) { scanResult ->
260 | when (scanResult) {
261 | is Result.Err -> onImportFailed(dialog, scanResult.err)
262 | is Result.Ok -> FirebaseIndexUpdater().executeWithCallback(sortStickers(scanResult.value)) { updateResult ->
263 | when (updateResult) {
264 | is Result.Err -> onImportFailed(dialog, updateResult.err)
265 | is Result.Ok -> onImportSuccess(dialog, scanResult.value.sumBy { it.stickers.size })
266 | }
267 | }
268 | }
269 | }
270 | }
271 | }
272 |
--------------------------------------------------------------------------------
/app/src/main/java/com/crossbowffs/usticker/Sticker.kt:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.usticker
2 |
3 | import android.net.Uri
4 |
5 | /**
6 | * Represents a sticker file with an associated URI and filesystem name.
7 | */
8 | class Sticker(
9 | private val documentId: String,
10 | val name: String)
11 | {
12 | /**
13 | * Returns the URI that is used to load sticker files from our
14 | * provider. This is an externally visible content:// URI.
15 | */
16 | fun getFileUri(): Uri {
17 | return Uri.Builder()
18 | .scheme("content")
19 | .authority(BuildConfig.APPLICATION_ID + ".provider")
20 | .appendPath(documentId)
21 | .build()
22 | }
23 |
24 | /**
25 | * Returns a unique URI representing this sticker in Firebase.
26 | * It has no real meaning (we could replace it with a random UUID),
27 | * the only requirement is that it is unique per sticker.
28 | */
29 | fun getFirebaseUri(stickerPackUri: Uri): Uri {
30 | return stickerPackUri.buildUpon().appendPath(name).build()
31 | }
32 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/crossbowffs/usticker/StickerPack.kt:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.usticker
2 |
3 | import android.net.Uri
4 |
5 | /**
6 | * Represents a collection of stickers with an associated filesystem path.
7 | */
8 | class StickerPack(
9 | val path: String,
10 | val stickers: List)
11 | {
12 | /**
13 | * Returns a unique URI representing this sticker pack in Firebase.
14 | * It has no real meaning (we could replace it with a random UUID),
15 | * the only requirement is that it is unique per sticker pack.
16 | */
17 | fun getFirebaseUri(): Uri {
18 | return Uri.Builder()
19 | .scheme("usticker")
20 | .authority("sticker")
21 | .path(path)
22 | .build()
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/crossbowffs/usticker/StickerProvider.kt:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.usticker
2 |
3 | import android.content.ContentProvider
4 | import android.content.ContentValues
5 | import android.net.Uri
6 | import android.os.ParcelFileDescriptor
7 | import android.provider.DocumentsContract
8 | import java.io.FileNotFoundException
9 |
10 | /**
11 | * Provides sticker files to the Firebase indexing service.
12 | * Only supports reading files via openFile().
13 | */
14 | class StickerProvider : ContentProvider() {
15 | override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
16 | val path = uri.lastPathSegment ?: throw FileNotFoundException("Invalid URI: $uri")
17 | Klog.i("Requesting sticker: $path")
18 | val stickerDir = Prefs.getStickerDir(context!!) ?: throw FileNotFoundException("Sticker directory not set")
19 | val fileUri = DocumentsContract.buildDocumentUriUsingTree(stickerDir, path)
20 | try {
21 | return context!!.contentResolver.openFileDescriptor(fileUri, mode)
22 | } catch (e: Exception) {
23 | Klog.e("openFileDescriptor() failed", e)
24 | throw e
25 | }
26 | }
27 |
28 | override fun onCreate() = true
29 | override fun query(
30 | uri: Uri,
31 | projection: Array?,
32 | selection: String?,
33 | selectionArgs: Array?,
34 | sortOrder: String?) = null
35 | override fun getType(uri: Uri) = null
36 | override fun insert(uri: Uri, values: ContentValues?) = null
37 | override fun delete(
38 | uri: Uri,
39 | selection: String?,
40 | selectionArgs: Array?) = 0
41 | override fun update(
42 | uri: Uri,
43 | values: ContentValues?,
44 | selection: String?,
45 | selectionArgs: Array?) = 0
46 | }
47 |
--------------------------------------------------------------------------------
/app/src/main/java/com/crossbowffs/usticker/StickerScanner.kt:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.usticker
2 |
3 | import android.content.ContentResolver
4 | import android.net.Uri
5 | import android.provider.DocumentsContract
6 |
7 | /**
8 | * Scans stickers starting from a given parent directory.
9 | */
10 | class StickerScanner(private val resolver: ContentResolver) : FailableAsyncTask>() {
11 | companion object {
12 | private val STICKER_MIME_TYPES = arrayOf(
13 | "image/jpeg",
14 | "image/png",
15 | "image/gif",
16 | "image/bmp",
17 | "image/webp"
18 | )
19 | }
20 |
21 | /**
22 | * Standard filesystem traversal algorithm, calls cb for each file
23 | * (not directory!) it finds. Does not yield/recurse into
24 | * files/directories with '.' as the first character in the name.
25 | */
26 | private fun traverseDirectory(
27 | rootDir: Uri,
28 | packPath: MutableList,
29 | dirDocumentId: String,
30 | cb: (Array, Sticker) -> Unit)
31 | {
32 | resolver.query(
33 | DocumentsContract.buildChildDocumentsUriUsingTree(rootDir, dirDocumentId),
34 | arrayOf(
35 | DocumentsContract.Document.COLUMN_DOCUMENT_ID,
36 | DocumentsContract.Document.COLUMN_DISPLAY_NAME,
37 | DocumentsContract.Document.COLUMN_MIME_TYPE
38 | ),
39 | null,
40 | null,
41 | null
42 | )?.use {
43 | while (it.moveToNext()) {
44 | val documentId = it.getString(0)
45 | val name = it.getString(1)
46 | val mimeType = it.getString(2)
47 | if (name.startsWith('.')) {
48 | continue
49 | } else if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
50 | packPath.add(name)
51 | traverseDirectory(rootDir, packPath, documentId, cb)
52 | packPath.removeAt(packPath.lastIndex)
53 | } else if (mimeType in STICKER_MIME_TYPES) {
54 | cb(packPath.toTypedArray(), Sticker(documentId, name))
55 | }
56 | }
57 | }
58 | }
59 |
60 | /**
61 | * Returns a collection of sticker packs. Each sticker pack is guaranteed
62 | * to have at least one file. The ordering is not guaranteed, however.
63 | */
64 | override fun run(arg: Uri): List {
65 | val rootDocumentId = DocumentsContract.getTreeDocumentId(arg)
66 | val stickerMap = mutableMapOf>()
67 | traverseDirectory(arg, mutableListOf(), rootDocumentId) { packPath, sticker ->
68 | stickerMap.getOrPut(packPath.joinToString("/"), ::mutableListOf).add(sticker)
69 | }
70 | return stickerMap.map { entry -> StickerPack(entry.key, entry.value) }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/app/src/main/java/com/crossbowffs/usticker/StickerSortOrder.kt:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.usticker
2 |
3 | enum class StickerSortOrder {
4 | /**
5 | * Legacy sort order (aka no sorting whatsoever, order of
6 | * stickers is determined by system).
7 | */
8 | NONE,
9 |
10 | /**
11 | * Alphabetical sort order.
12 | */
13 | ALPHABETICAL
14 | }
15 |
--------------------------------------------------------------------------------
/app/src/main/java/com/crossbowffs/usticker/TooManyStickersException.kt:
--------------------------------------------------------------------------------
1 | package com.crossbowffs.usticker
2 |
3 | class TooManyStickersException(
4 | val count: Int,
5 | val limit: Int,
6 | val path: String
7 | ) : Exception()
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apsun/uSticker/732ec3c75bb78b566925c92e119b34920165d987/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apsun/uSticker/732ec3c75bb78b566925c92e119b34920165d987/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apsun/uSticker/732ec3c75bb78b566925c92e119b34920165d987/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apsun/uSticker/732ec3c75bb78b566925c92e119b34920165d987/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apsun/uSticker/732ec3c75bb78b566925c92e119b34920165d987/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/values/arrays.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | - @string/sticker_sort_order_none
5 | - @string/sticker_sort_order_alphabetical
6 |
7 |
8 | - NONE
9 | - ALPHABETICAL
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #66828F
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | uSticker
4 | Got it!
5 | Actions
6 | About
7 | Close
8 | Report bug
9 | Help
10 | GitHub
11 | Version
12 | View app usage instructions
13 |
14 |
18 |
19 | Have too many stickers to scroll through? Put your stickers
20 | in sub-directories, and we\'ll generate a new sticker tab for
21 | each.
22 |
23 | Only PNG, JPG, GIF, BMP, and WebP files are supported. Any
24 | files or directories with a name starting with . are ignored.
25 | ]]>
26 |
27 | Changelog
28 |
29 | 1.6.0
31 | Added an option to sort stickers by alphabetical order.
32 |
33 |
34 | 1.5.4
35 | Gboard now limits you to 100 stickers per pack, so added a
36 | validation check for that. Also removed the bug report
37 | button as it was encouraging spam.
38 |
39 |
40 | 1.5.3
41 | Removed unused storage permission. Fixed incorrect sticker
42 | count calculation.
43 |
44 |
45 | 1.5.2
46 | No longer crashes when failing to find a file browser app.
47 | Also added a button to make reporting bugs easier.
48 |
49 |
50 | 1.5.1
51 | Changed sticker keywords (for search) to be Unicode-aware.
52 |
53 |
54 | 1.5.0
55 | Use Storage Access Framework to select sticker directory.
56 | This means you can now import stickers from an external
57 | SD card. Also removed support for Android 4.4.
58 |
59 |
60 | 1.4.4
61 | Fixed crash if the configured sticker directory does not
62 | exist. Also reworded some text to be more understandable.
63 |
64 |
65 | 1.4.3
66 | Fixed index refresh failing if you have a lot of stickers.
67 | Again.
68 |
69 |
70 | 1.4.2
71 | Added support for WebP images.
72 |
73 |
74 | 1.4.1
75 | Fixed a bug that caused the app to crash when requesting
76 | storage permissions on some phones.
77 |
78 |
79 | 1.4
80 | Disabled the sneaky Firebase analytics that were enabled by
81 | default.
82 |
83 |
84 | 1.3
85 | Added support for Android 4.4 through 6.0.
86 |
87 |
88 | 1.2
89 | No longer fails if you have over 1000 stickers. Seriously
90 | though, why do you have over 1000 stickers?!
91 |
92 |
93 | 1.1
94 | You can now search for stickers in Gboard! Keywords are
95 | generated from the sticker file and parent directory names.
96 |
97 |
98 | 1.0
99 | Initial release.
100 | ]]>
101 |
102 | github.com/apsun/uSticker
103 | Import stickers to Gboard
104 | Press after adding stickers or changing settings
105 | Importing stickers to Gboard…
106 | Successfully imported %1$d sticker(s)
107 | Import failed
108 |
109 | An unknown error occurred while importing stickers. If you would like to help improve
110 | uSticker, please submit an issue on GitHub using the button below. Thank you!\n\n%1$s
111 |
112 | Change sticker directory
113 | Select a new directory to search for stickers
114 | Change sticker sort order
115 | None
116 | Alphabetical
117 | Could not read stickers; please re-select the sticker directory
118 | Failed to obtain read permissions
119 |
120 | Cannot find an app that supports ACTION_OPEN_DOCUMENT_TREE. If you disabled the
121 | built-in Android file browser, please re-enable it to use uSticker.
122 |
123 |
124 | uSticker version: %1$s
125 | \nGboard version: %2$s
126 | \nAndroid version: %3$s
127 |
128 |
129 | uSticker version: %1$s
130 | \nGboard version: %2$s
131 | \nAndroid version: %3$s
132 | \nException stacktrace:
133 | \n```\n%4$s```
134 |
135 | Details copied to clipboard. Please submit an issue on GitHub.
136 |
137 | Only %1$d stickers per pack are supported by Gboard. You have
138 | %2$d stickers in %3$s. Please move some of your stickers to
139 | subdirectories to create new sticker packs.
140 |
141 | the root sticker directory
142 | the directory \'%1$s\'
143 |
144 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
9 |
13 |
19 |
20 |
21 |
25 |
29 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | buildscript {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | jcenter()
6 | }
7 |
8 | dependencies {
9 | classpath("com.android.tools.build:gradle:4.1.2")
10 | classpath("com.google.gms:google-services:4.3.5")
11 | classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.30")
12 | }
13 | }
14 |
15 | allprojects {
16 | repositories {
17 | google()
18 | mavenCentral()
19 | jcenter()
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | android.useAndroidX=true
2 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | include(":app")
2 |
--------------------------------------------------------------------------------
/web/feature_graphic_1024x500.pdn:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apsun/uSticker/732ec3c75bb78b566925c92e119b34920165d987/web/feature_graphic_1024x500.pdn
--------------------------------------------------------------------------------
/web/feature_graphic_1024x500.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apsun/uSticker/732ec3c75bb78b566925c92e119b34920165d987/web/feature_graphic_1024x500.png
--------------------------------------------------------------------------------
/web/ic_launcher-web.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apsun/uSticker/732ec3c75bb78b566925c92e119b34920165d987/web/ic_launcher-web.png
--------------------------------------------------------------------------------