├── .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 --------------------------------------------------------------------------------