diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 3e181ab..3a521e8 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,3 +1,5 @@
+import java.util.Properties
+
plugins {
id("com.android.application")
id("kotlin-android")
@@ -54,6 +56,33 @@ android {
setProperty("archivesBaseName", "$applicationId-$versionName")
}
+ signingConfigs {
+ // A release build has to be signed to be installable at all. Put your own key details in
+ // keystore.properties (storeFile, storePassword, keyAlias, keyPassword) to sign with those;
+ // without that file this falls back to the standard debug key, which is fine for a build only
+ // ever sideloaded onto your own phone and has the advantage of installing over an existing
+ // debug build rather than making you uninstall and lose your imported stickers.
+ // Do not hand a debug-signed APK to anyone else.
+ create("sideload") {
+ val properties = Properties()
+ val keystoreFile = rootProject.file("keystore.properties")
+ if (keystoreFile.exists()) {
+ keystoreFile.inputStream().use { properties.load(it) }
+ }
+ if (properties.isEmpty) {
+ storeFile = File(System.getProperty("user.home"), ".android/debug.keystore")
+ storePassword = "android"
+ keyAlias = "androiddebugkey"
+ keyPassword = "android"
+ } else {
+ storeFile = rootProject.file(properties.getProperty("storeFile"))
+ storePassword = properties.getProperty("storePassword")
+ keyAlias = properties.getProperty("keyAlias")
+ keyPassword = properties.getProperty("keyPassword")
+ }
+ }
+ }
+
buildTypes {
getByName("debug") {
versionNameSuffix = "-debug"
@@ -61,6 +90,7 @@ android {
getByName("release") {
proguardFiles("proguard-android-optimize.txt", "proguard-rules.pro")
isMinifyEnabled = false
+ signingConfig = signingConfigs.getByName("sideload")
}
}
@@ -84,6 +114,7 @@ dependencies {
implementation("androidx.gridlayout:gridlayout:1.0.0")
implementation("io.noties.markwon:core:4.6.2")
implementation("com.elvishew:xlog:1.11.1")
+ testImplementation("junit:junit:4.13.2")
androidTestImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test:core:1.6.1")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
diff --git a/app/src/main/java/com/fredhappyface/ewesticker/ImageKeyboard.kt b/app/src/main/java/com/fredhappyface/ewesticker/ImageKeyboard.kt
index 0dc9736..6c9ecf5 100644
--- a/app/src/main/java/com/fredhappyface/ewesticker/ImageKeyboard.kt
+++ b/app/src/main/java/com/fredhappyface/ewesticker/ImageKeyboard.kt
@@ -2,9 +2,12 @@ package com.fredhappyface.ewesticker
import android.content.Context
import android.content.SharedPreferences
+import android.content.res.Configuration
import android.inputmethodservice.InputMethodService
+import android.net.Uri
import android.os.Build
import android.os.Build.VERSION.SDK_INT
+import android.provider.DocumentsContract
import android.view.GestureDetector
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
@@ -12,6 +15,7 @@ import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
+import android.widget.HorizontalScrollView
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.RelativeLayout
@@ -22,27 +26,59 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.Coil
import coil.ImageLoader
+import coil.decode.BitmapFactoryDecoder
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.decode.VideoFrameDecoder
import coil.imageLoader
import coil.load
+import coil.memory.MemoryCache
import com.elvishew.xlog.XLog
+import com.fredhappyface.ewesticker.adapter.AllPacksAdapter
import com.fredhappyface.ewesticker.adapter.StickerPackAdapter
import com.fredhappyface.ewesticker.model.StickerPack
import com.fredhappyface.ewesticker.utilities.Cache
+import com.fredhappyface.ewesticker.utilities.StickerKeySet
+import com.fredhappyface.ewesticker.utilities.SourceListing
import com.fredhappyface.ewesticker.utilities.StickerClickListener
import com.fredhappyface.ewesticker.utilities.StickerSender
import com.fredhappyface.ewesticker.utilities.Toaster
import com.fredhappyface.ewesticker.utilities.startLogger
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import java.io.File
import kotlin.math.abs
import kotlin.math.min
+/** Tag of the synthetic tab holding starred stickers */
+private const val FAVOURITES_PACK = "__favourites__"
+
+/** Tag of the synthetic tab holding recently sent stickers */
+private const val RECENT_PACK = "__recentSticker__"
+
+/** Ceiling on how much of the source tree is walked when checking a pack is really empty */
+private const val MAX_TREE_ENTRIES = 4096
+
+/** Formats the platform's own bitmap decoder can read, so a still frame can be requested */
+private val PLATFORM_DECODABLE =
+ setOf("webp", "gif", "png", "jpg", "jpeg", "heif", "heic", "bmp")
+
+/** How long a delete stays armed before it forgets it was asked */
+private const val DELETE_ARM_MS = 4000L
+
private const val SWIPE_THRESHOLD = 1
private const val SWIPE_VELOCITY_THRESHOLD = 1
+/** Height the vertical grid starts at before the user has ever dragged the handle. */
+private const val DEFAULT_VERTICAL_HEIGHT = 800
+
+/** Ceiling for a dragged height, as a fraction of the screen, so the target app keeps a strip. */
+private const val MAX_HEIGHT_FRACTION = 0.85f
+
/**
* ImageKeyboard class inherits from the InputMethodService class - provides the keyboard
* functionality
@@ -60,6 +96,9 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
private var iconSize = 0
private var insensitiveSort = false
private var isPngFallback = true
+ private var continuousScroll = true
+ private var generateGifVariants = false
+ private var animateGrid = false
// Constants
private lateinit var internalDir: File
@@ -74,6 +113,7 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
// Caches
private var compatCache = Cache()
private var recentCache = Cache()
+ private lateinit var favourites: StickerKeySet
// onStartInput
private lateinit var stickerSender: StickerSender
@@ -81,13 +121,35 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
// onCreateInputView
private lateinit var keyboardRoot: ViewGroup
private lateinit var packsList: ViewGroup
+ private var packsScroller: HorizontalScrollView? = null
private lateinit var packContent: ViewGroup
private var keyboardHeight = 0
private var fullIconSize = 0
private var qwertyWidth = 0
+ private lateinit var resizeHandle: View
+
+ // Drag state. Each MOVE is measured from where the finger went down rather than from the
+ // previous frame, so the height cannot drift as rounding errors accumulate over a long drag.
+ private var dragStartRawY = 0f
+ private var dragStartHeight = 0
+
+ // The all-packs list, plus where each pack's header sits in it, held highest position first so
+ // that a scroll can find the pack currently in view by taking the first entry at or above it.
+ private var allPacksView: RecyclerView? = null
+ private var packHeaderPositions: Map = emptyMap()
+ private var packHeadersByPosition: List> = emptyList()
+
private lateinit var gestureDetector: GestureDetector
+ /**
+ * The configuration generation this keyboard has applied. MainActivity bumps the stored one
+ * whenever a preference or the sticker library changes, which is how a running keyboard notices
+ * without being restarted by hand.
+ */
+ private var appliedVersion = 0
+ private var rebuildInputView = false
+
/**
* When the activity is created...
* - ensure coil can decode (and display) animated images
@@ -102,7 +164,6 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
XLog.i("=".repeat(80))
XLog.i("Loaded $packageName:${javaClass.name}")
- val scale = baseContext.resources.displayMetrics.density
// Setup coil
val imageLoader =
ImageLoader.Builder(baseContext)
@@ -115,6 +176,11 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
add(VideoFrameDecoder.Factory())
add(SvgDecoder.Factory())
}
+ // Stated rather than inherited: this process draws a grid of several hundred stickers
+ // and the default is a share of an app heap that was never sized with that in mind.
+ .memoryCache {
+ MemoryCache.Builder(baseContext).maxSizePercent(0.25).build()
+ }
.build()
Coil.setImageLoader(imageLoader)
// Shared Preferences
@@ -125,18 +191,41 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
XLog.i("Loading private shared preferences: ${this.sharedPreferences.all}")
XLog.i("Loading backup shared preferences: ${this.backupSharedPreferences.all}")
+ this.internalDir = File(filesDir, "stickers")
+ this.toaster = Toaster(baseContext)
+ this.favourites = StickerKeySet(this.backupSharedPreferences, "favourites")
+
+ loadPreferences()
+ loadPacks()
+ this.appliedVersion = this.backupSharedPreferences.getInt("libraryVersion", 0)
+
+ loadCaches()
+ window.window?.navigationBarColor = getColor(R.color.bg)
+ }
+
+ /**
+ * Read every preference the keyboard acts on, plus the sizing derived from them. Split out of
+ * onCreate so a running keyboard can pick up a settings change instead of having to be restarted.
+ */
+ private fun loadPreferences() {
+ val scale = baseContext.resources.displayMetrics.density
this.restoreOnClose = this.backupSharedPreferences.getBoolean("restoreOnClose", false)
this.vertical = this.backupSharedPreferences.getBoolean("vertical", false)
this.scroll = this.backupSharedPreferences.getBoolean("scroll", false)
this.vibrate = this.backupSharedPreferences.getBoolean("vibrate", true)
this.insensitiveSort = this.backupSharedPreferences.getBoolean("insensitiveSort", false)
this.isPngFallback = this.backupSharedPreferences.getBoolean("isPngFallback", true)
+ this.continuousScroll = this.backupSharedPreferences.getBoolean("continuousScroll", true)
+ this.generateGifVariants =
+ this.backupSharedPreferences.getBoolean("generateGifVariants", false)
+ // Off by default: a still frame is memory-cacheable where an AnimatedImageDrawable is not, so
+ // the grid stops re-decoding every animated sticker as it scrolls. Verified on device that the
+ // platform decoder does return a first frame for an animated webp.
+ this.animateGrid = this.backupSharedPreferences.getBoolean("animateGrid", false)
this.iconsPerX = this.backupSharedPreferences.getInt("iconsPerX", 3)
this.totalIconPadding =
(resources.getDimension(R.dimen.sticker_padding) * 2 * (this.iconsPerX + 1)).toInt()
- // Constants
- this.internalDir = File(filesDir, "stickers")
this.iconSize =
(
if (this.vertical) {
@@ -145,32 +234,73 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
(this.backupSharedPreferences.getInt("iconSize", 80) * scale)
}
).toInt()
- this.toaster = Toaster(baseContext)
- // Load Packs
+ }
+
+ /**
+ * Scan the imported sticker tree. Rebuilds both collections from scratch rather than adding to
+ * them, since this now runs more than once per process.
+ */
+ private fun loadPacks() {
this.loadedPacks = HashMap()
val packs =
this.internalDir.listFiles { obj: File ->
obj.isDirectory && !obj.absolutePath.contains("__compatSticker__")
}
?: arrayOf()
+ var stickers = listOf()
for (file in packs) {
val pack = StickerPack(file)
if (pack.stickerList.isNotEmpty()) {
this.loadedPacks[file.name] = pack
}
- this.allStickers += pack.stickerList
+ stickers = stickers + pack.stickerList
}
-
+ this.allStickers = stickers
XLog.i("Loaded all packs: [${this.loadedPacks.keys.joinToString(", ")}]")
+ }
+
+ /**
+ * Restore the recents and compat caches, and which pack was last open.
+ *
+ * These have to be re-read on a reload, not just at startup: choosing a new sticker directory
+ * clears them in the settings screen, and a keyboard still holding the old ones writes them
+ * straight back over that reset in onFinishInput, leaving recents pointing at deleted files for
+ * good. The existing Cache instances are refilled rather than replaced because StickerSender
+ * captures compatCache by identity and would otherwise keep writing into an orphan.
+ */
+ private fun loadCaches() {
this.activePack = this.sharedPreferences.getString("activePack", "").toString()
- // Caches
- this.sharedPreferences.getString("recentCache", "")?.let {
- this.recentCache.fromSharedPref(it)
+ this.recentCache.fromSharedPref(this.sharedPreferences.getString("recentCache", "") ?: "")
+ this.compatCache.fromSharedPref(this.sharedPreferences.getString("compatCache", "") ?: "")
+ XLog.i(
+ "Loaded caches (recents=${this.recentCache.toFiles().size}, " +
+ "compat=${this.compatCache.toFiles().size}, activePack='${this.activePack}')",
+ )
+ }
+
+ /**
+ * Pick up a settings change or a re-import if one happened since this keyboard last looked. Only
+ * re-reads from disk when the stored generation actually moved, so focusing a field stays cheap.
+ */
+ private fun reloadIfConfigurationChanged() {
+ val version = this.backupSharedPreferences.getInt("libraryVersion", 0)
+ if (version == this.appliedVersion) {
+ return
}
- this.sharedPreferences.getString("compatCache", "")?.let {
- this.compatCache.fromSharedPref(it)
+ XLog.i("Configuration changed (v${this.appliedVersion} -> v$version), reloading")
+ loadPreferences()
+ loadPacks()
+ loadCaches()
+ this.appliedVersion = version
+ // onStartInputView is skipped when an app merely restarts input on a field it already owns,
+ // so a view that is on screen right now would otherwise keep rendering the previous library
+ // and sizing. Replace it here in that case, and defer otherwise.
+ if (isInputViewShown()) {
+ XLog.i("Rebuilding the visible input view for the new configuration")
+ setInputView(onCreateInputView())
+ } else {
+ this.rebuildInputView = true
}
- window.window?.navigationBarColor = getColor(R.color.bg)
}
/**
@@ -187,27 +317,156 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
this.keyboardRoot = keyboardLayout.findViewById(R.id.keyboardRoot)
this.packsList = keyboardLayout.findViewById(R.id.packsList)
+ this.packsScroller = keyboardLayout.findViewById(R.id.topHScrollView)
this.packContent = keyboardLayout.findViewById(R.id.packContent)
- this.keyboardHeight =
- if (this.vertical) {
- 800
- } else {
- this.iconSize * this.iconsPerX + this.totalIconPadding
- }
+ this.resizeHandle = keyboardLayout.findViewById(R.id.resizeHandle)
+ attachResizeHandle()
+ this.keyboardHeight = storedKeyboardHeight()
this.packContent.layoutParams?.height = this.keyboardHeight
- this.fullIconSize =
- (
- min(
- resources.displayMetrics.widthPixels,
- this.keyboardHeight -
- resources.getDimensionPixelOffset(R.dimen.text_size_body) * 2,
- ) * 0.95
- )
- .toInt()
+ this.fullIconSize = fullIconSizeFor(this.keyboardHeight)
createPackIcons()
+ // Whatever this returns is already built from the current configuration, so a pending
+ // rebuild is satisfied; leaving the flag set would inflate the whole keyboard a second time.
+ this.rebuildInputView = false
return keyboardLayout
}
+ /**
+ * Portrait and landscape keep separate heights. One shared value either overflows the shorter
+ * screen or wastes most of the taller one, and clamping cannot tell the two apart after the
+ * fact because it only ever sees the current screen.
+ */
+ private fun keyboardHeightKey(): String =
+ if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ "keyboardHeightLandscape"
+ } else {
+ "keyboardHeight"
+ }
+
+ /** What the keyboard was before the user ever dragged it. */
+ private fun defaultKeyboardHeight(): Int =
+ if (this.vertical) {
+ DEFAULT_VERTICAL_HEIGHT
+ } else {
+ this.iconSize * this.iconsPerX + this.totalIconPadding
+ }
+
+ // Two pack rows keeps a row of stickers plus its padding reachable at the smallest size.
+ private fun minKeyboardHeight(): Int =
+ resources.getDimensionPixelOffset(R.dimen.pack_dimens) * 2
+
+ private fun maxKeyboardHeight(): Int =
+ (resources.displayMetrics.heightPixels * MAX_HEIGHT_FRACTION).toInt()
+ .coerceAtLeast(minKeyboardHeight())
+
+ /**
+ * The height to open at: whatever was last dragged to, else the default. Clamped on the way
+ * out, because a height stored on one screen can be impossible on another -- a foldable being
+ * opened, or a display-size change -- and a stale value must never leave the keyboard unusable.
+ */
+ private fun storedKeyboardHeight(): Int {
+ val stored = this.backupSharedPreferences.getInt(keyboardHeightKey(), 0)
+ val height = if (stored > 0) stored else defaultKeyboardHeight()
+ return height.coerceIn(minKeyboardHeight(), maxKeyboardHeight())
+ }
+
+ /**
+ * Long-press preview image size for a given keyboard height. The preview surface carries a
+ * header row and a caption around the image, so the image has to leave room for them or the
+ * surface grows past the keyboard and clips.
+ */
+ private fun fullIconSizeFor(height: Int): Int {
+ val previewChrome = resources.getDimensionPixelOffset(R.dimen.pack_dimens) +
+ resources.getDimensionPixelOffset(R.dimen.text_size_body) * 4
+ return (min(resources.displayMetrics.widthPixels, height - previewChrome) * 0.92)
+ .toInt()
+ .coerceAtLeast(resources.getDimensionPixelOffset(R.dimen.pack_dimens))
+ }
+
+ /**
+ * Drag the handle to resize the keyboard. Guarded end to end: an exception escaping a touch
+ * listener kills the keyboard mid-typing, so a failure here abandons the gesture instead.
+ */
+ private fun attachResizeHandle() {
+ this.resizeHandle.setOnTouchListener { view, event ->
+ try {
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ this.dragStartRawY = event.rawY
+ this.dragStartHeight = this.keyboardHeight
+ view.isPressed = true
+ true
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ // The keyboard grows upward from the bottom of the screen, so a finger
+ // moving towards the top -- a falling rawY -- has to increase the height.
+ applyKeyboardHeight(
+ this.dragStartHeight + (this.dragStartRawY - event.rawY).toInt(),
+ )
+ true
+ }
+
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
+ view.isPressed = false
+ // apply(), not commit(): this is the thread drawing the keyboard.
+ this.backupSharedPreferences.edit()
+ .putInt(keyboardHeightKey(), this.keyboardHeight)
+ .apply()
+ if (event.actionMasked == MotionEvent.ACTION_UP) {
+ view.performClick()
+ }
+ true
+ }
+
+ else -> false
+ }
+ } catch (e: Throwable) {
+ XLog.e("Resize gesture failed, abandoning it")
+ XLog.e(e)
+ this.resizeHandle.isPressed = false
+ false
+ }
+ }
+ }
+
+ /** Apply a height mid-drag, bringing everything derived from the old one along with it. */
+ private fun applyKeyboardHeight(target: Int) {
+ val clamped = target.coerceIn(minKeyboardHeight(), maxKeyboardHeight())
+ if (clamped == this.keyboardHeight) {
+ return
+ }
+ this.keyboardHeight = clamped
+ this.packContent.layoutParams?.height = clamped
+ this.packContent.requestLayout()
+ this.fullIconSize = fullIconSizeFor(clamped)
+ resizeSearchResults(clamped)
+ }
+
+ /**
+ * Keep an open search view's results box in step. Rebuilding the view would be simpler but
+ * would throw away whatever has been typed, so only the box is resized here; the result cells
+ * pick up the new size on the next keystroke.
+ */
+ private fun resizeSearchResults(height: Int) {
+ val results = this.packContent.findViewById(R.id.search_results) ?: return
+ val chrome = resources.getDimension(R.dimen.qwerty_row_height) * 5
+ results.layoutParams?.height = (height - chrome)
+ .coerceAtLeast(resources.getDimension(R.dimen.pack_dimens))
+ .toInt()
+ results.requestLayout()
+ }
+
+ /** Install a freshly built input view when the configuration moved under us. */
+ override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
+ super.onStartInputView(info, restarting)
+ if (this.rebuildInputView) {
+ this.rebuildInputView = false
+ XLog.i("Rebuilding the input view for the new configuration")
+ setInputView(onCreateInputView())
+ }
+ }
+
/**
* Disable full-screen mode as content will likely be hidden by the IME.
*
@@ -224,6 +483,14 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
* @param restarting
*/
override fun onStartInput(info: EditorInfo?, restarting: Boolean) {
+ // Before building the sender, because it captures isPngFallback and generateGifVariants and
+ // would otherwise stay a session behind a change to either.
+ reloadIfConfigurationChanged()
+ // The outgoing sender may still be finishing a conversion; tell it not to act on a field it
+ // no longer owns.
+ if (this::stickerSender.isInitialized) {
+ this.stickerSender.abandon()
+ }
this.stickerSender = StickerSender(
this.baseContext,
this.toaster,
@@ -233,6 +500,7 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
this.compatCache,
this.imageLoader,
this.isPngFallback,
+ this.generateGifVariants,
)
}
@@ -244,6 +512,9 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
editor.putString("compatCache", this.compatCache.toSharedPref())
editor.putString("activePack", this.activePack)
editor.apply()
+ if (this::stickerSender.isInitialized) {
+ this.stickerSender.abandon()
+ }
super.onFinishInput()
if (restoreOnClose) {
closeKeyboard()
@@ -259,20 +530,15 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
private fun switchPackLayout(packName: String) {
XLog.i("Switching pack to '$packName'")
this.activePack = packName
- for (packCard in this.packsList) {
- val packButton = packCard.findViewById(R.id.stickerButton)
- if (packButton.tag == packName) {
- (packButton as ImageButton).setColorFilter(getColor(R.color.accent_a))
- } else {
- (packButton as ImageButton).setColorFilter(getColor(R.color.transparent))
- }
- }
+ highlightPackButton(packName)
- val stickers: Array
- if (packName == "__recentSticker__") {
- stickers = this.recentCache.toFiles().reversedArray()
- } else {
- stickers = loadedPacks[packName]?.stickerList ?: return
+ // Both synthetic tabs are filtered through isFile: they are built from stored paths, and an
+ // import that drops or renames a sticker would otherwise leave a blank cell that still commits
+ // a uri to a file which is not there -- reporting success and delivering nothing.
+ val stickers: Array = when (packName) {
+ FAVOURITES_PACK -> this.favourites.resolve(this.internalDir)
+ RECENT_PACK -> this.recentCache.toFiles().filter { it.isFile }.reversed().toTypedArray()
+ else -> loadedPacks[packName]?.stickerList ?: return
}
val recyclerView = RecyclerView(this)
val adapter = StickerPackAdapter(
@@ -280,7 +546,8 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
stickers,
this,
gestureDetector,
- this.vibrate
+ this.vibrate,
+ this.animateGrid,
)
val layoutManager = GridLayoutManager(
this,
@@ -290,24 +557,167 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
)
recyclerView.layoutManager = layoutManager
recyclerView.adapter = adapter
+ allPacksView = null
packContent.removeAllViewsInLayout()
packContent.addView(recyclerView)
}
/**
- * Set the current tab to the search page/ view
+ * Tint the pack bar so that only the given tab reads as selected.
+ *
+ * @param tag String tag of the pack button to mark active
*/
- private fun searchView() {
- XLog.i("Switching to search")
+ private fun highlightPackButton(tag: String) {
+ var activeCard: View? = null
for (packCard in this.packsList) {
val packButton = packCard.findViewById(R.id.stickerButton)
- if (packButton.tag == "__search__") {
- (packButton as ImageButton).setColorFilter(getColor(R.color.accent_a))
- } else {
- (packButton as ImageButton).setColorFilter(getColor(R.color.transparent))
+ val active = packButton.tag == tag
+ // A filled container behind the icon, not a colour filter over it. Tinting recoloured the
+ // sticker artwork itself, which is why a selected pack used to look washed out.
+ packCard.setBackgroundResource(if (active) R.drawable.pack_selected else 0)
+ if (active) {
+ activeCard = packCard
}
}
+ // With a button per pack the bar is several screens wide, so tinting a card means nothing
+ // unless the bar is also brought to it - otherwise the highlight that follows the sticker
+ // list is usually scrolled out of sight. Deferred because a card laid out this frame has no
+ // position yet.
+ val card = activeCard ?: return
+ val scroller = this.packsScroller ?: return
+ scroller.post {
+ val centred = card.left - (scroller.width - card.width) / 2
+ scroller.smoothScrollTo(centred.coerceAtLeast(0), 0)
+ }
+ }
+
+ /** Pack names in the order the pack bar shows them */
+ private fun sortedPackNames(): List =
+ if (this.insensitiveSort) {
+ this.loadedPacks.keys.sortedWith(String.CASE_INSENSITIVE_ORDER)
+ } else {
+ this.loadedPacks.keys.sorted()
+ }
+
+ /**
+ * Show every pack in one scrollable list, each behind a labelled divider, and optionally jump
+ * straight to one of them.
+ *
+ * @param scrollToPack String? pack to bring to the top, or null to stay at the beginning
+ */
+ private fun showAllPacks(scrollToPack: String?) {
+ val items = mutableListOf()
+ val positions = mutableMapOf()
+ for (packName in sortedPackNames()) {
+ val pack = this.loadedPacks[packName] ?: continue
+ positions[packName] = items.size
+ items.add(AllPacksAdapter.Item.PackHeader(packName))
+ pack.stickerList.forEach { items.add(AllPacksAdapter.Item.Sticker(it)) }
+ }
+ if (items.isEmpty()) {
+ return
+ }
+ this.packHeaderPositions = positions
+ this.packHeadersByPosition = positions.map { it.value to it.key }.sortedByDescending { it.first }
+
+ val adapter =
+ AllPacksAdapter(iconSize, items, this, gestureDetector, this.vibrate, this.animateGrid)
+ val layoutManager = GridLayoutManager(this, iconsPerX, RecyclerView.VERTICAL, false)
+ layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
+ // A pack label is a divider and a name, so it needs the whole row rather than one cell.
+ override fun getSpanSize(position: Int): Int =
+ if (adapter.isHeader(position)) iconsPerX else 1
+ }
+
+ val recyclerView = RecyclerView(this)
+ recyclerView.layoutManager = layoutManager
+ recyclerView.adapter = adapter
+ recyclerView.addOnScrollListener(
+ object : RecyclerView.OnScrollListener() {
+ /**
+ * Whether the list is moving because it was dragged. Jumping to a pack settles over
+ * several layout passes, and treating those as scrolling would walk the remembered
+ * pack through everything in between and save whichever one it stopped on.
+ */
+ private var dragged = false
+
+ override fun onScrollStateChanged(view: RecyclerView, state: Int) {
+ when (state) {
+ RecyclerView.SCROLL_STATE_DRAGGING -> dragged = true
+ RecyclerView.SCROLL_STATE_IDLE -> dragged = false
+ else -> Unit // settling after a fling still counts as the user's scroll
+ }
+ }
+
+ override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
+ if (!dragged) {
+ return
+ }
+ val first = layoutManager.findFirstVisibleItemPosition()
+ if (first == RecyclerView.NO_POSITION) {
+ return
+ }
+ // Keep the pack bar reflecting whatever is on screen, so scrolling past the end
+ // of a pack moves the highlight along with it.
+ val visiblePack =
+ packHeadersByPosition.firstOrNull { it.first <= first }?.second ?: return
+ if (visiblePack != activePack) {
+ XLog.i("Scrolled into pack '$visiblePack' (row $first)")
+ activePack = visiblePack
+ highlightPackButton(visiblePack)
+ }
+ }
+ },
+ )
+
+ packContent.removeAllViewsInLayout()
+ packContent.addView(recyclerView)
+ this.allPacksView = recyclerView
+
+ val target = scrollToPack ?: sortedPackNames().firstOrNull()
+ if (target != null) {
+ positions[target]?.let { layoutManager.scrollToPositionWithOffset(it, 0) }
+ this.activePack = target
+ highlightPackButton(target)
+ }
+ }
+
+ /**
+ * Bring a pack to the top of the all-packs list, building that list first if some other tab (the
+ * recents grid or the search page) is currently showing.
+ *
+ * @param packName String pack to scroll to
+ */
+ private fun jumpToPack(packName: String) {
+ val recyclerView = this.allPacksView
+ if (recyclerView == null || recyclerView.parent !== packContent) {
+ showAllPacks(packName)
+ return
+ }
+ val position = this.packHeaderPositions[packName] ?: return
+ (recyclerView.layoutManager as? GridLayoutManager)?.scrollToPositionWithOffset(position, 0)
+ this.activePack = packName
+ highlightPackButton(packName)
+ }
+
+ /** Show a pack, either by scrolling the combined list to it or by showing it on its own */
+ private fun selectPack(packName: String) {
+ if (this.continuousScroll && this.vertical) {
+ jumpToPack(packName)
+ } else {
+ switchPackLayout(packName)
+ }
+ }
+
+ /**
+ * Set the current tab to the search page/ view
+ */
+ private fun searchView() {
+ XLog.i("Switching to search")
+ highlightPackButton("__search__")
+ allPacksView = null
+
qwertyWidth = (resources.displayMetrics.widthPixels / 10.4).toInt()
val qwertyLayout = layoutInflater.inflate(R.layout.qwerty_layout, packContent, false)
@@ -321,20 +731,43 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
resources.getDimension(R.dimen.qwerty_row_height) * 4
)
- searchResults.layoutParams.height = searchResultsHeight.toInt()
+ // Never below one row: with few rows, a small icon size or a large font scale the qwerty rows
+ // can cost more than the whole keyboard height, and everything derived from this went negative.
+ val usableResultsHeight =
+ searchResultsHeight.coerceAtLeast(resources.getDimension(R.dimen.pack_dimens))
+ searchResults.layoutParams.height = usableResultsHeight.toInt()
fun searchStickers(query: String): List {
- return this.allStickers.filter { it.name.contains(query, ignoreCase = true) }
+ // Every whitespace-separated token has to appear somewhere in "pack/filename", so word
+ // order does not matter and a pack name narrows the results like any other term.
+ val terms = query.split(' ', '\t').filter { it.isNotBlank() }
+ if (terms.isEmpty()) {
+ return emptyList()
+ }
+ // Results are capped downstream, and matching pack names means one big pack can now fill
+ // that cap on its name alone. Rank a sticker whose own name matches above one that only
+ // matched through its pack, so the obvious hit is never the entry that gets truncated.
+ return this.allStickers.mapNotNull { sticker ->
+ val name = sticker.nameWithoutExtension
+ val pack = sticker.parentFile?.name.orEmpty()
+ val haystack = "$pack/$name"
+ if (!terms.all { haystack.contains(it, ignoreCase = true) }) {
+ return@mapNotNull null
+ }
+ val inName = terms.count { name.contains(it, ignoreCase = true) }
+ sticker to inName
+ }.sortedByDescending { it.second }.map { it.first }
}
fun updateSearchResults(stickers: List) {
val recyclerView = RecyclerView(baseContext)
val adapter = StickerPackAdapter(
- (searchResultsHeight * 0.9).toInt(),
+ (usableResultsHeight * 0.9).toInt().coerceAtLeast(1),
stickers.take(128).toTypedArray(),
this,
gestureDetector,
this.vibrate,
+ this.animateGrid,
)
val layoutManager = GridLayoutManager(
baseContext,
@@ -436,13 +869,23 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
val packCard = layoutInflater.inflate(R.layout.sticker_card, this.packsList, false)
val packButton = packCard.findViewById(R.id.stickerButton)
packButton.tag = tag
- packButton.setOnClickListener { switchPackLayout(it?.tag as String) }
+ packButton.setOnClickListener { selectPack(it?.tag as String) }
this.packsList.addView(packCard)
return packButton
}
/** Create the pack icons (image buttons) that when tapped switch the pack (switchPackLayout) */
private fun createPackIcons() {
+ buildPackBar()
+ restoreActivePack()
+ }
+
+ /**
+ * Rebuild the pack bar only. Kept separate from choosing what to show, so that something which
+ * merely changes the set of buttons -- starring the first sticker, unstarring the last -- does not
+ * also renavigate and throw away where the user had scrolled to.
+ */
+ private fun buildPackBar() {
this.packsList.removeAllViewsInLayout()
// Back button
if (this.backupSharedPreferences.getBoolean("showBackButton", true)) {
@@ -462,28 +905,167 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
}
}
// Recent
- val recentPackName = "__recentSticker__"
+ val recentPackName = RECENT_PACK
val recentButton = addPackButton(recentPackName)
recentButton.load(getDrawable(R.drawable.time))
recentButton.setOnClickListener { switchPackLayout(recentPackName) }
+ // Favourites, only once there is something in it -- an always-present empty tab is noise
+ if (!!this.favourites.hasAny(this.internalDir)) {
+ val favouritesButton = addPackButton(FAVOURITES_PACK)
+ favouritesButton.load(getDrawable(R.drawable.star_filled_circle))
+ favouritesButton.setOnClickListener { switchPackLayout(FAVOURITES_PACK) }
+ }
// Packs
- val sortedPackNames = if (this.insensitiveSort) {
- this.loadedPacks.keys.sortedWith(String.CASE_INSENSITIVE_ORDER)
- } else {
- this.loadedPacks.keys.sorted()
- }.toTypedArray()
+ val sortedPackNames = sortedPackNames().toTypedArray()
for (sortedPackName in sortedPackNames) {
val packButton = addPackButton(sortedPackName)
- packButton.load(this.loadedPacks[sortedPackName]?.thumbSticker)
- packButton.setOnClickListener { switchPackLayout(sortedPackName) }
+ val thumb = this.loadedPacks[sortedPackName]?.thumbSticker
+ packButton.load(thumb) {
+ // Always a still frame here regardless of the grid preference: an animated first
+ // sticker would otherwise run an AnimatedImageDrawable in a 40dp button for as long as
+ // the keyboard is open, and never be memory-cached.
+ if (thumb != null && thumb.extension.lowercase() in PLATFORM_DECODABLE) {
+ decoderFactory(BitmapFactoryDecoder.Factory())
+ }
+ }
+ packButton.setOnClickListener { selectPack(sortedPackName) }
}
+ }
+
+ /**
+ * Delete a sticker for good: from the source folder it was imported from, from the imported copy,
+ * and from everything generated off the back of it.
+ *
+ * Removing only the imported copy would be undone by the next import, which would copy it back
+ * from a source that still has it, so the source document goes first and the rest follows only if
+ * that succeeded.
+ *
+ * @param sticker File the imported sticker
+ * @param onDone (Boolean) -> Unit called on the main thread with whether it went
+ */
+ private fun deleteSticker(sticker: File, onDone: (Boolean) -> Unit) {
+ val pack = sticker.parentFile?.name
+ if (pack == null) {
+ onDone(false)
+ return
+ }
+ val treePath = this.sharedPreferences.getString("stickerDirPath", null)
+ CoroutineScope(Dispatchers.Main).launch {
+ val deleted = withContext(Dispatchers.IO) {
+ runCatching { removeEverywhere(sticker, pack, treePath) }
+ .getOrElse {
+ if (it is CancellationException) throw it
+ // An escape here would take the keyboard down mid-typing.
+ XLog.e("Failed to delete '${sticker.name}'")
+ XLog.e(it)
+ false
+ }
+ }
+ if (deleted) {
+ // The pack listing was snapshotted at load, so it still holds the deleted file.
+ loadPacks()
+ buildPackBar()
+ refreshContentAfterLibraryChange()
+ }
+ onDone(deleted)
+ }
+ }
+
+ /**
+ * The actual removal, off the main thread. Returns false without touching the imported copy when
+ * the source document is still there but could not be deleted, so the two never disagree.
+ */
+ private fun removeEverywhere(sticker: File, pack: String, treePath: String?): Boolean {
+ if (treePath != null) {
+ val tree = Uri.parse(treePath)
+ // FAILED is not ABSENT: if the lookup could not be completed, deleting the imported copy
+ // would only hide a sticker the next import brings straight back.
+ if (SourceListing.removeDocument(baseContext, tree, pack, sticker.name) ==
+ SourceListing.Removal.FAILED
+ ) {
+ return false
+ }
+ // tools/gif-variants.py writes its output as a sibling of the webp. Leaving that behind
+ // means the next import no longer sees a webp of the same name, so the orphan is imported
+ // as a sticker in its own right and the deleted sticker reappears as a GIF.
+ if (sticker.name.endsWith(".webp", ignoreCase = true)) {
+ SourceListing.removeDocument(
+ baseContext,
+ tree,
+ pack,
+ "${sticker.nameWithoutExtension}.gif",
+ )
+ }
+ } else {
+ XLog.w("No sticker directory is set, deleting the imported copy only")
+ }
+
+ // Everything derived from this sticker. Neither has another owner once it is gone, and the
+ // generated GIF in particular cannot be rebuilt from anything.
+ val variant = File(filesDir, "variants/$pack/${sticker.nameWithoutExtension}.gif")
+ val compat = File(internalDir, "__compatSticker__/${sticker.hashCode()}.png")
+ val derived = listOf(variant, compat).count { it.isFile && it.delete() }
+
+ favourites.remove(sticker)
+ recentCache.remove(sticker.absolutePath)
+ val removed = !sticker.exists() || sticker.delete()
+
+ // A pack that just lost its last sticker leaves an empty directory in all three trees.
+ val emptiedPack = File(internalDir, pack).listFiles()?.isEmpty() == true
+ if (emptiedPack) {
+ File(internalDir, pack).delete()
+ File(filesDir, "variants/$pack").takeIf { it.listFiles()?.isEmpty() == true }?.delete()
+ if (treePath != null) {
+ SourceListing.deletePackIfEmpty(baseContext, Uri.parse(treePath), pack, MAX_TREE_ENTRIES)
+ }
+ }
+
+ XLog.i(
+ "Deleted '$pack/${sticker.name}' (copy: $removed, derived files: $derived, " +
+ "pack emptied: $emptiedPack)",
+ )
+ return removed
+ }
+
+ /**
+ * Redraw the sticker area after the library itself changed.
+ *
+ * jumpToPack deliberately reuses an all-packs list that is already built and merely scrolls it,
+ * which is right when only the selection changed. It is wrong after a sticker is deleted: the
+ * adapter still holds a cell for a file that has gone, which draws as a blank gap instead of the
+ * grid closing up. Dropping the reference forces the list to be rebuilt from the reloaded packs.
+ */
+ private fun refreshContentAfterLibraryChange() {
+ this.allPacksView = null
+ this.packHeaderPositions = emptyMap()
+ this.packHeadersByPosition = emptyList()
+ restoreActivePack()
+ }
+
+ /** Show whichever tab was last open, falling back to the first pack */
+ private fun restoreActivePack() {
+ val sortedPackNames = sortedPackNames().toTypedArray()
+ // The synthetic tabs have to be members here or the keyboard forgets them on every restart and
+ // silently falls back to the first pack.
+ val syntheticTabs = buildList {
+ add(RECENT_PACK)
+ if (favourites.hasAny(internalDir)) add(FAVOURITES_PACK)
+ }
val targetPack =
- if (activePack in sortedPackNames + recentPackName) activePack else sortedPackNames.firstOrNull()
+ if (activePack in sortedPackNames + syntheticTabs) {
+ activePack
+ } else {
+ sortedPackNames.firstOrNull()
+ }
if (sortedPackNames.isNotEmpty()) {
- targetPack?.let { switchPackLayout(it) }
+ if (targetPack in syntheticTabs) {
+ switchPackLayout(targetPack!!)
+ } else {
+ targetPack?.let { selectPack(it) }
+ }
}
}
@@ -537,6 +1119,81 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
val packName = trimString(sticker.parent?.split('/')?.last())
fText.text = getString(R.string.sticker_pack_info, stickerName, packName)
+ val star = fullStickerLayout.findViewById(R.id.favouriteButton)
+ fun paintStar() {
+ star.load(
+ getDrawable(
+ if (favourites.contains(sticker)) {
+ R.drawable.star_filled_circle
+ } else {
+ R.drawable.star_circle
+ },
+ ),
+ )
+ }
+ paintStar()
+ star.setOnClickListener {
+ val tabWasShown = favourites.hasAny(internalDir)
+ val nowFavourite = favourites.toggle(sticker)
+ paintStar()
+ if (vibrate && SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ it.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_PRESS)
+ }
+ toaster.toast(
+ getString(
+ if (nowFavourite) R.string.favourite_added else R.string.favourite_removed,
+ ),
+ )
+ // The tab appears with the first star and goes with the last, so the bar needs rebuilding
+ // then -- but only then, and without renavigating, or starring would scroll the grid back
+ // to the top of the current pack under the preview the user is still looking at.
+ if (tabWasShown != favourites.hasAny(internalDir)) {
+ buildPackBar()
+ highlightPackButton(activePack)
+ // Unstarring the last favourite removes the tab the user may be standing on.
+ if (activePack == FAVOURITES_PACK && !favourites.hasAny(internalDir)) {
+ restoreActivePack()
+ }
+ } else if (activePack == FAVOURITES_PACK) {
+ // Still on the favourites tab, so its contents just changed under us.
+ switchPackLayout(FAVOURITES_PACK)
+ }
+ }
+
+ val deleteButton = fullStickerLayout.findViewById(R.id.deleteButton)
+ deleteButton.load(getDrawable(R.drawable.delete_circle))
+ var armed = false
+ val disarm = Runnable {
+ armed = false
+ deleteButton.load(getDrawable(R.drawable.delete_circle))
+ }
+ deleteButton.setOnClickListener {
+ if (vibrate && SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ it.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_PRESS)
+ }
+ if (!armed) {
+ // Deleting removes the file from the sticker folder, so it takes two deliberate taps.
+ armed = true
+ deleteButton.load(getDrawable(R.drawable.delete_confirm_circle))
+ toaster.toast(getString(R.string.delete_confirm))
+ deleteButton.postDelayed(disarm, DELETE_ARM_MS)
+ return@setOnClickListener
+ }
+ deleteButton.removeCallbacks(disarm)
+ deleteButton.isEnabled = false
+ deleteSticker(sticker) { deleted ->
+ toaster.toast(
+ getString(if (deleted) R.string.delete_done else R.string.delete_failed),
+ )
+ if (deleted) {
+ keyboardRoot.removeView(fullStickerLayout)
+ } else {
+ deleteButton.isEnabled = true
+ disarm.run()
+ }
+ }
+ }
+
// Tap to exit popup
fullStickerLayout.setOnClickListener { this.keyboardRoot.removeView(it) }
fSticker.setOnClickListener { this.keyboardRoot.removeView(fullStickerLayout) }
@@ -544,22 +1201,26 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
}
internal fun switchToPreviousPack() {
- // Get a list of sorted pack names
- val sortedPackNames = loadedPacks.keys.sorted()
- // Find the index of the current active pack
+ // sortedPackNames() rather than keys.sorted(), so swiping visits packs in the same order the
+ // pack bar shows them. With no packs at all the wrap-around below would index [-1].
+ val sortedPackNames = sortedPackNames()
+ if (sortedPackNames.isEmpty()) {
+ return
+ }
val currentIndex = sortedPackNames.indexOf(activePack)
- // Calculate the index of the previous pack, considering wrap-around
val previousIndex = if (currentIndex > 0) currentIndex - 1 else sortedPackNames.size - 1
- val previousPack = sortedPackNames[previousIndex]
- switchPackLayout(previousPack)
+ selectPack(sortedPackNames[previousIndex])
}
internal fun switchToNextPack() {
- val sortedPackNames = loadedPacks.keys.sorted()
+ // An empty library would make the modulo below a division by zero.
+ val sortedPackNames = sortedPackNames()
+ if (sortedPackNames.isEmpty()) {
+ return
+ }
val currentIndex = sortedPackNames.indexOf(activePack)
val nextIndex = (currentIndex + 1) % sortedPackNames.size
- val nextPack = sortedPackNames[nextIndex]
- switchPackLayout(nextPack)
+ selectPack(sortedPackNames[nextIndex])
}
private inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
@@ -578,6 +1239,7 @@ class ImageKeyboard : InputMethodService(), StickerClickListener {
if (
scroll &&
+ !(continuousScroll && vertical) &&
abs(if (vertical) diffX else diffY) > SWIPE_THRESHOLD &&
abs(if (vertical) velocityX else velocityY) > SWIPE_VELOCITY_THRESHOLD
) {
diff --git a/app/src/main/java/com/fredhappyface/ewesticker/MainActivity.kt b/app/src/main/java/com/fredhappyface/ewesticker/MainActivity.kt
index 132594e..dabaacb 100644
--- a/app/src/main/java/com/fredhappyface/ewesticker/MainActivity.kt
+++ b/app/src/main/java/com/fredhappyface/ewesticker/MainActivity.kt
@@ -18,12 +18,18 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import com.elvishew.xlog.XLog
+import com.fredhappyface.ewesticker.utilities.GifVariantGenerator
+import com.fredhappyface.ewesticker.utilities.LibraryScanner
import com.fredhappyface.ewesticker.utilities.StickerImporter
import com.fredhappyface.ewesticker.utilities.Toaster
import com.fredhappyface.ewesticker.utilities.startLogger
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.progressindicator.LinearProgressIndicator
import io.noties.markwon.Markwon
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
@@ -82,9 +88,12 @@ class MainActivity : AppCompatActivity() {
findViewById(R.id.iconSizeSb).isEnabled = !isChecked
}
toggle(findViewById(R.id.restoreOnClose), "restoreOnClose", false) {}
+ toggle(findViewById(R.id.continuousScroll), "continuousScroll", true) {}
toggle(findViewById(R.id.scroll), "scroll", false) {}
toggle(findViewById(R.id.insensitive_sort), "insensitiveSort", false) {}
toggle(findViewById(R.id.pngFallback), "isPngFallback", true) {}
+ toggle(findViewById(R.id.animateGrid), "animateGrid", false) {}
+ toggle(findViewById(R.id.generateGifs), "generateGifVariants", false) {}
val versionText: TextView = findViewById(R.id.versionText)
var version = getString(R.string.version_text)
@@ -189,6 +198,246 @@ class MainActivity : AppCompatActivity() {
*
* @param ignoredView: View
*/
+ /**
+ * The library operation currently running, if any. Importing, generating and deleting all walk
+ * the same two trees, so exactly one of them may run at a time: a delete landing in the middle of
+ * a generation wipes files it is still writing, and a second generation started from a re-enabled
+ * button converts everything twice into the same place.
+ */
+ private var libraryJob: Job? = null
+
+ /** The newest stats scan, so a slow earlier one cannot paint over a newer result */
+ private var scanJob: Job? = null
+
+ private val libraryBusy: Boolean
+ get() = libraryJob?.isActive == true
+
+ /** Every control that mutates or reports on the library, so busy state is applied in one place */
+ private fun libraryButtons(): List
+
+
+
+
+
+
+
@@ -268,6 +337,34 @@
android:text="@string/options_png_fallback" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/keyboard_layout.xml b/app/src/main/res/layout/keyboard_layout.xml
index a6054f6..42aeca9 100644
--- a/app/src/main/res/layout/keyboard_layout.xml
+++ b/app/src/main/res/layout/keyboard_layout.xml
@@ -8,12 +8,36 @@
android:fitsSystemWindows="true"
tools:context="com.fredhappyface.ewesticker.ImageKeyboard">
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/sticker_card.xml b/app/src/main/res/layout/sticker_card.xml
index d46ffab..af32e68 100644
--- a/app/src/main/res/layout/sticker_card.xml
+++ b/app/src/main/res/layout/sticker_card.xml
@@ -1,17 +1,16 @@
+ android:padding="@dimen/sticker_padding">
diff --git a/app/src/main/res/layout/sticker_preview.xml b/app/src/main/res/layout/sticker_preview.xml
index aec0dd1..ec48e40 100644
--- a/app/src/main/res/layout/sticker_preview.xml
+++ b/app/src/main/res/layout/sticker_preview.xml
@@ -1,32 +1,80 @@
+
+ android:layout_height="wrap_content"
+ android:background="@color/scrim">
-
-
-
-
-
+ android:layout_centerInParent="true"
+ android:layout_margin="@dimen/content_margin"
+ android:background="@drawable/preview_surface"
+ android:elevation="8dp"
+ android:orientation="vertical"
+ android:padding="@dimen/content_margin">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 857ec0b..16f404c 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -44,9 +44,7 @@
- الشيفرة المصدرية لEweSticker متاحة على https://github.com/FredHappyface/Android.EweSticker\n\n- تأخذ الدروس بيدك خلال سلسلة من الخطوات لبدء استخدام البرنامج. ابدأ من هنا إذا كنت جديدًا: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/tutorials\n\n- دليل المساعدة يوفر نقطة انطلاق ويوضح المشكلات الشائعة التي قد تواجهها: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/help\n\n- رخصة MIT\n(انظر الرخصة لمزيد من المعلومات https://github.com/FredHappyface/Android.EweSticker/blob/main/LICENSE.md )
معلومات موجزة
- تم تغيير التفضيلات. أعد تحميل لوحة المفاتيح لتطبيق الإعدادات
بدء عملية الاستيراد. قد يستغرق ذلك بعض الوقت!
- تم استيراد %1$d ملصق. أعد تحميل لوحة المفاتيح لعرض الملصقات الجديدة
E034: فشلت عملية إعادة تحميل الملصقات، حاول اختيار مجلد مصدر الملصقات
E041: حدث استثناء غير متوقع أثناء تحويل الملصق
diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml
index 73728f3..90058f2 100644
--- a/app/src/main/res/values-bn/strings.xml
+++ b/app/src/main/res/values-bn/strings.xml
@@ -42,9 +42,7 @@
লিঙ্কস
- EweStickerের উৎসকোড উপলব্ধ https://github.com/FredHappyface/Android.EweSticker\n\n- টিউটোরিয়ালগুলি আপনাকে সফলভাবে সফটওয়্যার ব্যবহার করার জন্য এক ধরনের ধরাধারিত ধাক্কা মেয়ে নিয়ে যায়। নতুন হলে এখানে শুরু করুন: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/tutorials\n\n- সাহায্য গাইড একটি শুরুপ্রয়াস প্রদান করে এবং আপনি যে সাধারণ সমস্যা নিয়ে বাধা পান তা প্রতিপাদন করে: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/help\n\n- মিট লাইসেন্স\n(আরও তথ্যের জন্য লাইসেন্সটি দেখুন https://github.com/FredHappyface/Android.EweSticker/blob/main/LICENSE.md )
- পছন্দস্থান পরিবর্তন হয়েছে। সেটিংস প্রয়োগ করার জন্য কীবোর্ড পুনরায় লোড করুন
আমদানি শুরু হয়েছে। এটি কিছু সময় নিতে পারে!
- %1$d টি স্টিকার আমদানি করা হয়েছে। নতুন স্টিকার দেখানোর জন্য কীবোর্ড পুনরায় লোড করুন
E034: স্টিকার পুনরায় লোড করা যায়নি, স্টিকার শোর্স ডিরেক্টরি চয়ন করার চেষ্টা করুন
E041: অপ্রত্যাশিত IOException যখন স্টিকার রুপান্তর করা হয়
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 02c7ca2..67b05ad 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -44,9 +44,7 @@
- Der Quellcode für EweSticker ist verfügbar unter https://github.com/FredHappyface/Android.EweSticker\n\n- Die Tutorials führen dich schrittweise durch eine Reihe von Schritten, um die Software zu verwenden. Starte hier, wenn du neu bist: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/tutorials\n\n- Der Hilfsleitfaden bietet einen Ausgangspunkt und erläutert häufig auftretende Probleme, die auftreten können: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/help\n\n- MIT Lizenz\n(Siehe die Lizenz für weitere Informationen https://github.com/FredHappyface/Android.EweSticker/blob/main/LICENSE.md )
Details zum Artikel
- Einstellungen geändert. Lade die Tastatur neu, damit die Einstellungen wirksam werden
Import wird gestartet. Dies könnte einige Zeit dauern!
- %1$d Sticker importiert. Lade die Tastatur neu, um neue Sticker anzuzeigen
E034: Neuladen der Sticker fehlgeschlagen, versuche ein Sticker-Quellverzeichnis auszuwählen
E041: Unerwarteter IOException beim Konvertieren des Stickers
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 12dc257..27b390d 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -44,9 +44,7 @@
- El código fuente de EweSticker está disponible en https://github.com/FredHappyface/Android.EweSticker\n\n- Los tutoriales te llevan de la mano a través de una serie de pasos para empezar a usar el software. Empiece aquí si es nuevo: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/tutorials\n\n- La guía de ayuda proporciona un punto de partida y esboza los problemas comunes que usted puede tener: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/help\n\n- Licencia MIT\n(Vea la licencia para más información https://github.com/FredHappyface/Android.EweSticker/blob/main/LICENSE.md )
Versión
- Preferencias modificadas. Vuelva a cargar el teclado para que se apliquen los ajustes
Comenzando la importación. ¡Esto puede llevar algún tiempo!
- Importado %1$d stickers. Recargar el teclado para que se muestren los nuevos stickers
E034: Falló la recarga de stickers, intente elegir un directorio de origen de los stickers
E041: IOException inesperado al convertir el sticker
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index beaea53..b0fbf86 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -36,9 +36,7 @@
Liens
- Le code source pour EweSticker est disponible à l\'adresse https://github.com/FredHappyface/Android.\n\n- Les tutoriels vous emmènent à la main par une série de étapes pour commencer à utiliser le logiciel. Commencez ici si vous êtes nouveau: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/tutorials\n\n- Le guide d\'aide fournit un point de départ et décrit les questions fréquentes que vous pouvez avoir: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/help\n\n- Licence MIT\n(Voir la licence pour plus d\'informations https://github.com/FredHappyface/Android.EweSticker/blob/main/LICENSE.md )
Version Info
- Les préférences ont changé. Recharger le clavier pour les appliquer
Démarrage de l’importation. Ceci va prendre du temps!
- Autocollants importés %1$d. Recharger le clavier pour afficher les nouveaux autocollants
%1$s (Pack : %2$s)
Activer le tri des paquets sensibles
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index c09b24e..3745217 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -43,9 +43,7 @@
- EweSticker का स्रोत कोड https://github.com/FredHappyface/Android.EweSticker पर उपलब्ध है\n\n- यदि आप नए हैं, तो यह सॉफ़्टवेयर उपयोग करना शुरू करने के लिए एक सीरीज के स्टेप्स के माध्यम से जाते हैं: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/tutorials\n\n- सहायता गाइड एक शुरुआती पॉइंट प्रदान करता है और आपके पास सामान्य समस्याएँ को आउटलाइन करता है जो आपके पास हो सकती हैं: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/help\n\n- MIT लाइसेंस\n(अधिक जानकारी के लिए लाइसेंस देखें https://github.com/FredHappyface/Android.EweSticker/blob/main/LICENSE.md )
संस्करण की जानकारी
- प्राथमिकताएँ बदल गईं। सेटिंग्स को लागू करने के लिए कीबोर्ड को पुनः लोड करें
आयात शुरू हुआ। इसमें कुछ समय लग सकता है!
- %1$d स्टिकर्स का आयात हुआ। नए स्टिकर्स दिखाने के लिए कीबोर्ड को पुनः लोड करें
E034: स्टिकर्स को पुनः लोड करने में विफलता हुई, कृपया स्टिकर स्रोत निर्दिष्ट करने का प्रयास करें
E041: स्टिकर कन्वर्ट करते समय अप्रत्याशित IOException
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index ee56b0a..7ec4daa 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -44,9 +44,7 @@
- Kode sumber untuk EweSticker tersedia di https://github.com/FredHappyface/Android.EweSticker\n\n- Tutorial ini memandu Anda melalui serangkaian langkah untuk mulai menggunakan perangkat lunak ini. Mulailah dari sini jika Anda masih baru: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/tutorials\n\n- Panduan bantuan menyediakan titik awal dan menguraikan masalah umum yang mungkin Anda alami: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/help\n\n- Lisensi MIT\n (Lihat lisensi untuk informasi lebih lanjut https://github.com/FredHappyface/Android.EweSticker/blob/main/LICENSE.md )
Info Versi
- Preferensi berubah. Muat ulang keyboard agar pengaturan dapat diterapkan
Mulai impor. Ini mungkin membutuhkan waktu!
- Mengimpor stiker %1$d. Muat ulang keyboard agar stiker baru dapat ditampilkan
E034: Gagal memuat ulang stiker, coba pilih direktori sumber stiker
E041: IOException yang tidak diharapkan saat mengonversi stiker
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 9576f66..88ad967 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -43,9 +43,7 @@
- EweStickerのソースコードは https://github.com/FredHappyface/Android.EweSticker で利用可能です。\n\n- チュートリアルでは、ソフトウェアの使用を開始する一連の手順を紹介しています。新規の場合はこちらから始めてください: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/tutorials\n\n- ヘルプガイドはスタート地点を提供し、一般的な問題について概説しています: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/help\n\n- MITライセンス\n(詳細についてはライセンスを参照 https://github.com/FredHappyface/Android.EweSticker/blob/main/LICENSE.md )
バージョン情報
- 設定が変更されました。設定が適用されるようにキーボードを再読み込みしてください
インポートを開始しました。しばらく時間がかかることがあります!
- %1$d ステッカーをインポートしました。新しいステッカーを表示するにはキーボードを再読み込みしてください
E034: ステッカーの再読み込みに失敗しました。ステッカーソースディレクトリを選択してみてください
E041: ステッカーを変換する際に予期しないIOExceptionが発生しました
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 5c31dd0..2bb35d7 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -44,9 +44,7 @@
- EweSticker의 소스 코드는 https://github.com/FredHappyface/Android.EweSticker에서 제공됩니다.\n\n- 튜토리얼은 소프트웨어 사용을 시작하기 위한 일련의 단계를 안내합니다. 처음 사용자라면 여기에서 시작하십시오: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/tutorials\n\n- 도움말 가이드는 시작점을 제공하고 일반적인 문제를 개요로 설명합니다: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/help\n\n- MIT 라이선스\n(자세한 정보는 라이선스에서 확인하십시오 https://github.com/FredHappyface/Android.EweSticker/blob/main/LICENSE.md )
버전 정보
- 설정이 변경되었습니다. 설정을 적용하려면 키보드를 다시 불러오세요
가져오기 시작. 시간이 걸릴 수 있습니다!
- %1$d개의 스티커를 가져왔습니다. 새 스티커를 표시하려면 키보드를 다시 불러오세요
E034: 스티커 다시 불러오기 실패. 스티커 소스 디렉터리를 선택해보세요
E041: 스티커 변환 중 예기치 않은 IOException 발생
diff --git a/app/src/main/res/values-night-v31/colors.xml b/app/src/main/res/values-night-v31/colors.xml
new file mode 100644
index 0000000..98cbd6b
--- /dev/null
+++ b/app/src/main/res/values-night-v31/colors.xml
@@ -0,0 +1,9 @@
+
+
+ @android:color/system_accent1_200
+ @android:color/system_accent1_900
+ @android:color/system_neutral1_800
+ @android:color/system_accent2_700
+ @android:color/system_accent2_100
+ @android:color/system_neutral2_400
+
diff --git a/app/src/main/res/values-night-v31/themes.xml b/app/src/main/res/values-night-v31/themes.xml
new file mode 100644
index 0000000..e9fe129
--- /dev/null
+++ b/app/src/main/res/values-night-v31/themes.xml
@@ -0,0 +1,5 @@
+
+ @android:color/system_neutral1_900
+ @android:color/system_neutral1_100
+ @android:color/system_neutral2_800
+
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..97aba6b
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,9 @@
+
+
+ #232827
+ #00504f
+ #9df0ee
+ #899391
+ #440fa3a2
+ #b3000000
+
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index f345c49..1c51226 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -43,9 +43,7 @@
- O código-fonte do EweSticker está disponível em https://github.com/FredHappyface/Android.EweSticker\n\n- Os tutoriais o guiarão passo a passo para começar a usar o software. Comece aqui se você for iniciante: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/tutorials\n\n- O guia de ajuda fornece um ponto de partida e destaca problemas comuns que você pode encontrar: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/help\n\n- Licença MIT\n(Consulte a licença para obter mais informações em https://github.com/FredHappyface/Android.EweSticker/blob/main/LICENSE.md )
Informação da Versão
- Preferências alteradas. Recarregue o teclado para que as configurações se apliquem
Iniciando a importação. Isso pode levar algum tempo!
- Importados %1$d adesivos. Recarregue o teclado para mostrar os novos adesivos
E034: Falha ao recarregar adesivos, tente escolher um diretório de origem dos adesivos
E041: IOException inesperada ao converter adesivo
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index e31ba92..9c7d3e3 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -43,9 +43,7 @@
- Исходный код EweSticker доступен по адресу https://github.com/FredHappyface/Android.EweSticker\n\n- Учебники предоставляют пошаговые инструкции по началу работы с программным обеспечением. Начните с этого, если вы новичок: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/tutorials\n\n- Руководство помощи предоставляет отправную точку и описывает общие проблемы, с которыми вы можете столкнуться: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/help\n\n- Лицензия MIT\n(См. лицензию для получения дополнительной информации по адресу https://github.com/FredHappyface/Android.EweSticker/blob/main/LICENSE.md )
Версия
- Изменены настройки. Перезагрузите клавиатуру, чтобы настройки вступили в силу
Начало импорта. Это может занять некоторое время!
- Импортировано %1$d стикеров. Перезагрузите клавиатуру, чтобы отобразить новые стикеры
E034: Перезагрузка стикеров не удалась, попробуйте выбрать каталог источника стикеров
E041: Неожиданная ошибка ввода-вывода при преобразовании стикера
diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml
index 03bea7c..a0c5303 100644
--- a/app/src/main/res/values-ur/strings.xml
+++ b/app/src/main/res/values-ur/strings.xml
@@ -42,9 +42,7 @@
لنکس
- EweSticker کا سورس کوڈ https://github.com/FredHappyface/Android.EweSticker پر دستیاب ہے۔\n\n- ٹیوٹوریلز آپ کو استعمال کرنے کے لئے ایک سلسلہ کاروائیوں کے ذریعے لے جاتے ہیں۔ اگر آپ نئے ہیں تو یہاں سے شروع کریں: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/tutorials\n\n- مدد گائیڈ عمدہ آغاز فراہم کرتا ہے اور آپ کی سامنے آنے والی عام مسائل کی مختصر جائزہ دیتا ہے: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/help\n\n- MIT لائسنس\n(مزید معلومات کے لئے لائسنس دیکھیں https://github.com/FredHappyface/Android.EweSticker/blob/main/LICENSE.md )
- ترتیبات تبدیل ہوگئی ہیں. ترتیبات کو اطلاق کرنے کے لئے کی بورڈ دوبارہ لوڈ کریں
شروع کریں منتقلی. اس میں کچھ وقت لگ سکتا ہے!
- %1$d اسٹکر منتقل کر لئے گئے ہیں. نئے اسٹکرز دکھانے کے لئے کی بورڈ دوبارہ لوڈ کریں
E034: اسٹکرز دوبارہ لوڈ کرنے میں ناکامی. ایک اسٹکر سورس ڈائریکٹری منتخب کرنے کا کوشش کریں
E041: اسٹکر کو تبدیل کرنے کے دوران غیر متوقع IOException کا اعلان
diff --git a/app/src/main/res/values-v31/colors.xml b/app/src/main/res/values-v31/colors.xml
new file mode 100644
index 0000000..a65d5ef
--- /dev/null
+++ b/app/src/main/res/values-v31/colors.xml
@@ -0,0 +1,9 @@
+
+
+ @android:color/system_accent1_600
+ @android:color/system_accent1_0
+ @android:color/system_neutral2_100
+ @android:color/system_accent2_100
+ @android:color/system_accent1_900
+ @android:color/system_neutral2_500
+
diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml
new file mode 100644
index 0000000..76eb164
--- /dev/null
+++ b/app/src/main/res/values-v31/themes.xml
@@ -0,0 +1,6 @@
+
+
+ @android:color/system_neutral1_50
+ @android:color/system_neutral1_900
+ @android:color/system_neutral2_100
+
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 70a6061..eeb8326 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -44,9 +44,7 @@
- EweSticker 的源代码可在 https://github.com/FredHappyface/Android.EweSticker 上获得\n\n- 教程将逐步引导您开始使用该软件。如果您是新手,请从这里开始: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/tutorials\n\n- 帮助指南提供了一个起点,并概述了您可能遇到的常见问题: https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/help\n\n- MIT 许可证\n(有关更多信息,请参阅许可证 https://github.com/FredHappyface/Android.EweSticker/blob/main/LICENSE.md)
信息
- 首选项已更改。重新加载键盘以应用设置
开始导入。这可能需要一些时间!
- 已导入 %1$d 个贴纸。重新加载键盘以显示新贴纸
E034: 重新加载贴纸失败,请尝试选择贴纸源目录
E041: 在转换贴纸时发生意外的 IOException
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index aaddaec..1e20440 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -44,9 +44,7 @@
- EweSticker 的原始碼可以在 https://github.com/FredHappyface/Android.EweSticker 上獲得\n\n- 教程將逐步引導你完成一系列步驟以開始使用軟體。如果你是新手,請從這裏開始:https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/tutorials\n\n- 幫助指南提供了一個起點,並概述了您可能遇到的常見問題:https://github.com/FredHappyface/Android.EweSticker/blob/main/documentation/help\n\n- MIT 許可證\n(有關更多資訊,請參閱許可證 https://github.com/FredHappyface/Android.EweSticker/blob/main/LICENSE.md)
信息
- 偏好設定已變更。重新載入鍵盤以套用設定
正在開始匯入。這可能需要一些時間!
- 已匯入 %1$d 個貼圖。重新載入鍵盤以顯示新貼圖
E034:重新載入貼圖失敗,請嘗試選擇貼圖來源目錄
E041:在轉換貼圖時發生意外的 IOException
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index efc7f38..cec4839 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -4,4 +4,13 @@
#880fa3a2
#0000
#e6e6e6
+
+
+ #eef3f2
+ #b6e7e5
+ #00201f
+ #6f7978
+ #330fa3a2
+ #99000000
diff --git a/app/src/main/res/values/dimen.xml b/app/src/main/res/values/dimen.xml
index cd7aaf4..b463c91 100644
--- a/app/src/main/res/values/dimen.xml
+++ b/app/src/main/res/values/dimen.xml
@@ -16,4 +16,14 @@
16dp
40dp
4dp
+ 14dp
+ 18dp
+ 28dp
+ 6dp
+ 0.08
+
+ 20dp
+ 40dp
+ 4dp
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index bc8b7bb..feb4ebb 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -3,6 +3,13 @@
EweSticker
Pack icon
Sticker icon
+ Delete this sticker
+ Tap again to delete this sticker
+ Deleted from your sticker folder
+ Could not delete that sticker, see the logs
+ Star or unstar this sticker
+ Starred, find it in the star tab
+ Unstarred
[tap to close sticker preview]
Enable Keyboard
@@ -11,12 +18,35 @@
Update Sticker Pack
Choose sticker source directory
Reload stickers
+ Full re-import (rebuild everything)
Current loaded sticker packs information:
- Path:
Not Set
- Date:
Never
- Total:
+
+ Library
+ Reading library…
+ No stickers imported yet
+ %1$d stickers in %2$d packs (%3$s)
+ %1$d animated, %2$d with a GIF version (%3$s)
+ Every animated sticker has a GIF version
+
+ - %1$d animated sticker has no GIF version, so it arrives as a still image in apps that only accept GIF
+ - %1$d animated stickers have no GIF version, so they arrive as still images in apps that only accept GIF
+
+ Generate missing GIF versions
+ Delete GIF versions
+ Delete GIF versions?
+ This frees %1$s. Animated stickers will arrive as still images in apps that only accept GIF until you generate them again.
+ Delete
+ Deleted GIF versions, freed %1$s
+ Generated %1$d GIF version(s)
+ Something went wrong reading or changing the library, see the logs
+ Could not generate any GIF versions, see the logs
+ Generated %1$d of %2$d GIF versions, the rest failed
+ Every animated sticker already has a GIF version
Options
Show back button in navbar
@@ -27,6 +57,9 @@
Enable swipe between packs (perpendicular to scroll direction)
Enable case-insensitive pack sorting
Enable PNG sticker fallback if sticker format isn\'t supported
+ Scroll through every pack in one list, with a heading per pack (vertical layout only)
+ Play animated stickers in the grid. Turning this off shows a still frame instead, which scrolls faster and uses less battery
+ Make GIF versions of animated stickers, so they still animate in apps that only accept GIF (slows down importing)
"Number of Rows: "
"Icon size: "
@@ -75,14 +108,20 @@ Copyright © Randy Zhou
Logs
Get log file
- Preferences changed. Reload the keyboard for settings to apply
+ Preferences saved
Starting import. This might take some time!
- Imported %1$d stickers. Reload the keyboard for new stickers to show
+ Imported %1$d stickers
E031: Found more than %1$d stickers in total
E032: Found more than %1$d stickers in pack (%2$s)
E033: Unsupported format found (%1$s, file: %2$s/%3$s)
E034: Reloading stickers failed, try choosing a sticker source directory
+ Making GIF versions of %1$d animated stickers, this takes a while
+ Could not read the sticker folder properly, so nothing was removed. Use Full re-import if the library looks wrong.
E041: Unexpected IOException when converting sticker
+ Sent as an image, this app can\'t show animation
+ Making a GIF version of this sticker…
+ Sent as an image, no GIF version of this sticker yet
%1$s (Pack: %2$s)
+ Drag to change the keyboard height
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
index 12a332e..7639be7 100644
--- a/app/src/main/res/xml/file_paths.xml
+++ b/app/src/main/res/xml/file_paths.xml
@@ -3,4 +3,8 @@
+
+
diff --git a/app/src/test/java/com/fredhappyface/ewesticker/utilities/GifEncoderTest.kt b/app/src/test/java/com/fredhappyface/ewesticker/utilities/GifEncoderTest.kt
new file mode 100644
index 0000000..8dbb14d
--- /dev/null
+++ b/app/src/test/java/com/fredhappyface/ewesticker/utilities/GifEncoderTest.kt
@@ -0,0 +1,147 @@
+package com.fredhappyface.ewesticker.utilities
+
+import java.io.ByteArrayOutputStream
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * GifEncoder writes a byte format by hand, so what is worth asserting is that decoding its output
+ * gives back what went in. These run on the JVM rather than a device because the encoder deliberately
+ * depends on nothing from Android.
+ */
+class GifEncoderTest {
+ private fun encode(
+ frames: List,
+ width: Int,
+ height: Int,
+ colors: Int = 128,
+ delayMs: Int = 100,
+ ): ByteArray {
+ val out = ByteArrayOutputStream()
+ val encoder = GifEncoder(out, width, height, colors)
+ frames.forEach { encoder.addFrame(it, delayMs) }
+ encoder.finish()
+ return out.toByteArray()
+ }
+
+ @Test
+ fun `writes a well formed gif`() {
+ val width = 32
+ val height = 24
+ val frames = (0 until 5).map { frame ->
+ IntArray(width * height) { pixel ->
+ val x = pixel % width
+ 0xFF000000.toInt() or (((x * 8 + frame * 20) and 0xFF) shl 16) or (frame * 40 and 0xFF)
+ }
+ }
+
+ val bytes = encode(frames, width, height)
+ assertEquals("GIF89a", String(bytes, 0, 6, Charsets.US_ASCII))
+ assertEquals(0x3B.toByte(), bytes.last())
+
+ val gif = GifReader.read(bytes)
+ assertEquals(width, gif.width)
+ assertEquals(height, gif.height)
+ assertEquals(frames.size, gif.frames.size)
+ gif.frames.forEach {
+ assertEquals(width, it.width)
+ assertEquals(height, it.height)
+ assertEquals(width * height, it.pixels.size)
+ }
+ }
+
+ @Test
+ fun `reproduces flat colour exactly`() {
+ // With far fewer distinct colours than palette slots, quantisation has no excuse to
+ // approximate anything, so this should survive the round trip untouched.
+ val width = 16
+ val height = 16
+ val red = 0xFFCC2211.toInt()
+ val blue = 0xFF1122CC.toInt()
+ val frame = IntArray(width * height) { if (it % width < width / 2) red else blue }
+
+ val decoded = GifReader.read(encode(listOf(frame), width, height)).frames.single()
+
+ assertEquals(red, decoded.pixels[0])
+ assertEquals(blue, decoded.pixels[width - 1])
+ }
+
+ @Test
+ fun `keeps transparent pixels transparent`() {
+ val width = 8
+ val height = 8
+ val opaque = 0xFF00FF00.toInt()
+ val frame = IntArray(width * height) { if (it % 2 == 0) opaque else 0 }
+
+ val decoded = GifReader.read(encode(listOf(frame), width, height)).frames.single()
+
+ assertEquals(0, decoded.pixels[1])
+ assertEquals(opaque, decoded.pixels[0])
+ }
+
+ @Test
+ fun `respects the palette budget`() {
+ // A gradient holds far more colours than the budget allows, so the encoder has to reduce it
+ // and must not emit a colour table bigger than it declared.
+ val width = 64
+ val height = 64
+ val frame = IntArray(width * height) { pixel ->
+ val x = pixel % width
+ val y = pixel / width
+ 0xFF000000.toInt() or ((x * 4) shl 16) or ((y * 4) shl 8) or ((x + y) * 2 and 0xFF)
+ }
+
+ val decoded = GifReader.read(encode(listOf(frame), width, height, colors = 16))
+ .frames
+ .single()
+
+ assertTrue("colour table was ${decoded.paletteSize}", decoded.paletteSize <= 16)
+ assertTrue(
+ "expected at most 16 distinct colours, saw ${decoded.pixels.toSet().size}",
+ decoded.pixels.toSet().size <= 16,
+ )
+ }
+
+ @Test
+ fun `converts frame delays to centiseconds`() {
+ val frame = IntArray(4) { 0xFF808080.toInt() }
+ val gif = GifReader.read(encode(listOf(frame), 2, 2, delayMs = 83))
+ assertEquals(8, gif.frames.single().delayCentiseconds)
+ }
+
+ @Test
+ fun `never emits a zero delay`() {
+ // A zero delay leaves the frame rate up to the viewer, so a very short frame has to round up.
+ val frame = IntArray(4) { 0xFF808080.toInt() }
+ val gif = GifReader.read(encode(listOf(frame), 2, 2, delayMs = 1))
+ assertTrue(gif.frames.single().delayCentiseconds >= 1)
+ }
+
+ @Test
+ fun `survives a single pixel frame`() {
+ val gif = GifReader.read(encode(listOf(intArrayOf(0xFF123456.toInt())), 1, 1))
+ assertEquals(1, gif.frames.single().pixels.size)
+ assertEquals(0xFF123456.toInt(), gif.frames.single().pixels[0])
+ }
+
+ @Test
+ fun `handles a photographic frame without losing pixels`() {
+ // Enough pseudo-random colour to push the LZW dictionary past its first few widenings and
+ // force a code size increase, which is where a hand-written encoder tends to go wrong.
+ val width = 96
+ val height = 96
+ var seed = 12345
+ val frame = IntArray(width * height) {
+ seed = seed * 1103515245 + 12345
+ 0xFF000000.toInt() or ((seed ushr 8) and 0xFFFFFF)
+ }
+
+ val decoded = GifReader.read(encode(listOf(frame), width, height, colors = 256))
+ .frames
+ .single()
+
+ assertEquals(width * height, decoded.pixels.size)
+ assertTrue("decode produced only blanks", decoded.pixels.toSet().size > 1)
+ }
+}
diff --git a/app/src/test/java/com/fredhappyface/ewesticker/utilities/GifReader.kt b/app/src/test/java/com/fredhappyface/ewesticker/utilities/GifReader.kt
new file mode 100644
index 0000000..eb8771f
--- /dev/null
+++ b/app/src/test/java/com/fredhappyface/ewesticker/utilities/GifReader.kt
@@ -0,0 +1,191 @@
+package com.fredhappyface.ewesticker.utilities
+
+/**
+ * A minimal GIF reader, for asserting in tests that what GifEncoder wrote is what a decoder sees.
+ *
+ * Android unit tests compile against android.jar, which has no javax.imageio, so there is no stock
+ * decoder available to check against. Reading the format back by hand is the alternative, and it has
+ * the advantage of failing loudly on exactly the structural mistakes a hand-written encoder makes.
+ *
+ * @property width Int canvas width from the logical screen descriptor
+ * @property height Int canvas height from the logical screen descriptor
+ * @property frames List every image in the file, in order
+ */
+class GifReader private constructor(
+ val width: Int,
+ val height: Int,
+ val frames: List,
+) {
+ /**
+ * @property pixels IntArray ARGB pixels, transparent entries being fully zero
+ * @property delayCentiseconds Int delay declared for this frame
+ * @property paletteSize Int number of entries in the colour table this frame used
+ */
+ class Frame(
+ val width: Int,
+ val height: Int,
+ val pixels: IntArray,
+ val delayCentiseconds: Int,
+ val paletteSize: Int,
+ )
+
+ companion object {
+ fun read(bytes: ByteArray): GifReader {
+ require(String(bytes, 0, 6, Charsets.US_ASCII) == "GIF89a") { "not a GIF89a file" }
+ var pos = 6
+ val width = readShort(bytes, pos)
+ val height = readShort(bytes, pos + 2)
+ val screenPacked = bytes[pos + 4].toInt() and 0xFF
+ pos += 7
+ require(screenPacked and 0x80 == 0) { "these tests expect no global colour table" }
+
+ val frames = mutableListOf()
+ var delay = 0
+ var transparentIndex = -1
+
+ while (pos < bytes.size) {
+ when (bytes[pos].toInt() and 0xFF) {
+ 0x3B -> return GifReader(width, height, frames)
+
+ 0x21 -> {
+ val label = bytes[pos + 1].toInt() and 0xFF
+ pos += 2
+ if (label == 0xF9) {
+ val size = bytes[pos].toInt() and 0xFF
+ val packed = bytes[pos + 1].toInt() and 0xFF
+ delay = readShort(bytes, pos + 2)
+ transparentIndex =
+ if (packed and 0x01 != 0) bytes[pos + 4].toInt() and 0xFF else -1
+ pos += 1 + size
+ }
+ pos = skipBlocks(bytes, pos)
+ }
+
+ 0x2C -> {
+ val frameWidth = readShort(bytes, pos + 5)
+ val frameHeight = readShort(bytes, pos + 7)
+ val packed = bytes[pos + 9].toInt() and 0xFF
+ pos += 10
+ require(packed and 0x80 != 0) { "expected a local colour table" }
+ val paletteSize = 1 shl ((packed and 0x07) + 1)
+ val palette = IntArray(paletteSize) {
+ val at = pos + it * 3
+ ((bytes[at].toInt() and 0xFF) shl 16) or
+ ((bytes[at + 1].toInt() and 0xFF) shl 8) or
+ (bytes[at + 2].toInt() and 0xFF)
+ }
+ pos += paletteSize * 3
+
+ val minCodeSize = bytes[pos].toInt() and 0xFF
+ pos++
+ val data = mutableListOf()
+ while (true) {
+ val length = bytes[pos].toInt() and 0xFF
+ pos++
+ if (length == 0) {
+ break
+ }
+ for (i in 0 until length) {
+ data.add(bytes[pos + i])
+ }
+ pos += length
+ }
+
+ val indices = inflate(data.toByteArray(), minCodeSize, frameWidth * frameHeight)
+ val pixels = IntArray(indices.size) {
+ val index = indices[it]
+ if (index == transparentIndex) {
+ 0
+ } else {
+ 0xFF000000.toInt() or palette[index]
+ }
+ }
+ frames.add(Frame(frameWidth, frameHeight, pixels, delay, paletteSize))
+ }
+
+ else -> throw IllegalStateException(
+ "unexpected block 0x${(bytes[pos].toInt() and 0xFF).toString(16)} at $pos",
+ )
+ }
+ }
+ throw IllegalStateException("file ended without a trailer")
+ }
+
+ private fun skipBlocks(bytes: ByteArray, start: Int): Int {
+ var pos = start
+ while (true) {
+ val length = bytes[pos].toInt() and 0xFF
+ pos++
+ if (length == 0) {
+ return pos
+ }
+ pos += length
+ }
+ }
+
+ /** Standard GIF LZW decode, the inverse of what GifEncoder emits */
+ private fun inflate(data: ByteArray, minCodeSize: Int, expected: Int): IntArray {
+ val clearCode = 1 shl minCodeSize
+ val endCode = clearCode + 1
+ val table = arrayOfNulls(4096)
+ for (i in 0 until clearCode) {
+ table[i] = intArrayOf(i)
+ }
+
+ var codeSize = minCodeSize + 1
+ var next = endCode + 1
+ var previous: IntArray? = null
+ val out = IntArray(expected)
+ var written = 0
+ var bitPos = 0
+
+ while (written < expected) {
+ var code = 0
+ var readable = true
+ for (bit in 0 until codeSize) {
+ val index = bitPos + bit
+ if (index / 8 >= data.size) {
+ readable = false
+ break
+ }
+ code = code or (((data[index / 8].toInt() shr (index % 8)) and 1) shl bit)
+ }
+ if (!readable || code == endCode) {
+ break
+ }
+ bitPos += codeSize
+
+ if (code == clearCode) {
+ codeSize = minCodeSize + 1
+ next = endCode + 1
+ previous = null
+ continue
+ }
+
+ val known = table[code]
+ // A code can legally refer to the entry it is about to define, which always means
+ // "the previous sequence plus its own first symbol".
+ val entry = known ?: previous?.let { it + it[0] } ?: break
+ for (value in entry) {
+ if (written < expected) {
+ out[written++] = value
+ }
+ }
+ previous?.let {
+ if (next < 4096) {
+ table[next] = it + entry[0]
+ next++
+ if (next > (1 shl codeSize) - 1 && codeSize < 12) {
+ codeSize++
+ }
+ }
+ }
+ previous = entry
+ }
+ return out
+ }
+
+ private fun readShort(bytes: ByteArray, at: Int): Int =
+ (bytes[at].toInt() and 0xFF) or ((bytes[at + 1].toInt() and 0xFF) shl 8)
+ }
+}
diff --git a/app/src/test/java/com/fredhappyface/ewesticker/utilities/ImageResamplerTest.kt b/app/src/test/java/com/fredhappyface/ewesticker/utilities/ImageResamplerTest.kt
new file mode 100644
index 0000000..7e9d03a
--- /dev/null
+++ b/app/src/test/java/com/fredhappyface/ewesticker/utilities/ImageResamplerTest.kt
@@ -0,0 +1,80 @@
+package com.fredhappyface.ewesticker.utilities
+
+import kotlin.math.abs
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ImageResamplerTest {
+ private fun solid(width: Int, height: Int, argb: Int) = IntArray(width * height) { argb }
+
+ @Test
+ fun `returns the source untouched when nothing changes`() {
+ val source = solid(4, 4, 0xFF102030.toInt())
+ val result = ImageResampler.resize(source, 4, 4, 4, 4)
+ assertEquals(source.toList(), result.toList())
+ }
+
+ @Test
+ fun `produces the requested dimensions`() {
+ val result = ImageResampler.resize(solid(512, 512, 0xFF808080.toInt()), 512, 512, 320, 200)
+ assertEquals(320 * 200, result.size)
+ }
+
+ @Test
+ fun `preserves a flat colour through a downscale`() {
+ // Lanczos has negative lobes, so a uniform image is the case that catches a normalisation
+ // bug: the weights must sum to one or flat colour drifts or overshoots.
+ val colour = 0xFF3C7A28.toInt()
+ val result = ImageResampler.resize(solid(512, 512, colour), 512, 512, 320, 320)
+ result.forEach { assertEquals(colour, it) }
+ }
+
+ @Test
+ fun `keeps fully transparent input transparent`() {
+ val result = ImageResampler.resize(solid(64, 64, 0), 64, 64, 32, 32)
+ result.forEach { assertEquals(0, it) }
+ }
+
+ @Test
+ fun `does not bleed colour out of transparent pixels`() {
+ // Half opaque red, half fully transparent but carrying blue in its unused colour channels.
+ // Premultiplying means that hidden blue must not tint the opaque side.
+ val width = 32
+ val height = 8
+ val source = IntArray(width * height) { index ->
+ if (index % width < width / 2) 0xFFFF0000.toInt() else 0x000000FF
+ }
+ val result = ImageResampler.resize(source, width, height, width / 2, height / 2)
+
+ val leftEdge = result[0]
+ assertEquals(255, leftEdge ushr 24)
+ assertTrue("blue leaked in: ${leftEdge.toString(16)}", (leftEdge and 0xFF) < 8)
+ }
+
+ @Test
+ fun `keeps a gradient monotonic`() {
+ val size = 256
+ val source = IntArray(size * size) { index ->
+ val x = index % size
+ 0xFF000000.toInt() or (x shl 16) or (x shl 8) or x
+ }
+ val result = ImageResampler.resize(source, size, size, 64, 64)
+
+ var previous = -1
+ for (x in 0 until 64) {
+ val value = result[x] and 0xFF
+ assertTrue("value went backwards at $x", value >= previous - 1)
+ previous = value
+ }
+ // Ends should still span most of the original range rather than being washed out.
+ assertTrue(abs((result[63] and 0xFF) - (result[0] and 0xFF)) > 200)
+ }
+
+ @Test
+ fun `handles upscaling`() {
+ val result = ImageResampler.resize(solid(8, 8, 0xFF204060.toInt()), 8, 8, 24, 24)
+ assertEquals(24 * 24, result.size)
+ result.forEach { assertEquals(0xFF204060.toInt(), it) }
+ }
+}
diff --git a/docs/code-map.md b/docs/code-map.md
new file mode 100644
index 0000000..5631743
--- /dev/null
+++ b/docs/code-map.md
@@ -0,0 +1,116 @@
+# Code map
+
+File → responsibility. Read this before grepping; scope edits to the files named here.
+
+Upstream is [FredHappyface/Android.EweSticker](https://github.com/FredHappyface/Android.EweSticker).
+This is a personal fork; everything marked **(fork)** does not exist upstream.
+
+## Entry points
+
+| File | Responsibility |
+|---|---|
+| `ImageKeyboard.kt` | The `InputMethodService`. Owns the keyboard view, pack bar, search, preview, gestures, drag-to-resize height, and the reload-on-change mechanism **(fork)**. The biggest file; most keyboard behaviour lives here. |
+| `MainActivity.kt` | Settings screen. Directory picking, import triggers, the Library card **(fork)**, option toggles, log export. |
+
+## Sending a sticker
+
+| File | Responsibility |
+|---|---|
+| `utilities/StickerSender.kt` | Decides *what file* to send and *under which mimetype*, then commits it. Holds the fallback ladder and the on-demand GIF conversion **(fork)**. See `docs/sending.md`. |
+| `utilities/Utils.kt` | Mimetype from file extension, and the list of formats the app accepts on import. |
+| `utilities/Cache.kt` | Fixed-capacity ordered set, persisted to prefs. Two instances: recents, and converted PNG fallbacks. |
+| `utilities/StickerKeySet.kt` | **(fork)** A named set of stickers held as relative `pack/filename` keys so it survives a re-import. Backs favourites; `resolve()` prunes keys whose files are gone. |
+| `res/xml/file_paths.xml` | `FileProvider` roots. Both `stickers/` and `variants/` **(fork)** must be listed or committing throws. |
+
+## Importing
+
+| File | Responsibility |
+|---|---|
+| `utilities/StickerImporter.kt` | Plans and performs the import. Incremental and non-destructive **(fork)**. See `docs/import.md`. |
+| `utilities/SourceListing.kt` | **(fork)** Walks the SAF tree with one cursor per directory, yielding name/mime/size/mtime, and reports whether the walk was complete. |
+| `utilities/ImportManifest.kt` | **(fork)** Persisted record of what was copied, so the next import can tell changed from unchanged. |
+| `utilities/LibraryScanner.kt` | **(fork)** Measures the imported library for the Library card, and deletes generated GIFs. |
+
+Deleting a single sticker lives in `ImageKeyboard.removeEverywhere`, with the source-side work in
+`SourceListing.removeDocument` and `SourceListing.deletePackIfEmpty`. See "Deleting" in
+`docs/import.md` — it is the only code that removes files from the user's own storage.
+
+## GIF generation
+
+See `docs/gif-pipeline.md` for how these fit together and the quality numbers.
+
+| File | Responsibility |
+|---|---|
+| `utilities/GifVariantGenerator.kt` | **(fork)** Orchestrates webp → GIF conversion across cores; one conversion per sticker, temp file then rename. |
+| `utilities/WebpAnimation.kt` | **(fork)** Demuxes an animated webp by hand and hands over composited frames. Also `isAnimated`, the cheap animation probe used all over. |
+| `utilities/GifEncoder.kt` | **(fork)** GIF89a writer: median cut, Floyd–Steinberg dithering, LZW. No Android dependencies, so it is unit-testable on the JVM. |
+| `utilities/ImageResampler.kt` | **(fork)** Lanczos-3 resize on ARGB. Exists because `Bitmap.createScaledBitmap` only does bilinear, which loses against the desktop reference. |
+| `tools/gif-variants.py` | **(fork)** Desktop reference implementation and the quality benchmark the on-device encoder is measured against. |
+
+## Keyboard view
+
+| File | Responsibility |
+|---|---|
+| `adapter/AllPacksAdapter.kt` | **(fork)** Every pack in one scrollable list with a heading per pack. |
+| `adapter/StickerPackAdapter.kt` | A single pack, or search results. |
+| `adapter/StickerBinding.kt` | **(fork)** Shared sticker cell binding, so the two adapters cannot drift apart. |
+| `view/StickerPackViewHolder.kt`, `view/PackHeaderViewHolder.kt` | Cell and heading holders. |
+| `model/StickerPack.kt` | A pack directory: its stickers and its thumbnail. |
+| `res/layout/keyboard_layout.xml` | Drag handle (`resizeHandle`), then the pack bar (`topHScrollView` + `packsList`), above a `packContent` container that everything else is swapped into. |
+| `res/drawable/resize_grabber.xml` | **(fork)** The grab indicator inside the resize handle. Uses the `outline` role so it reads as chrome, not as something tappable. |
+| `res/layout/pack_header.xml` | **(fork)** Divider and pack name inside the combined list. |
+| `res/layout/sticker_card.xml` | One cell. Shared by the grid, the search strip and the pack bar, so a change here shows up in all three. |
+| `res/layout/sticker_preview.xml` | **(fork)** The long-press surface: delete and star controls, the enlarged sticker, and a scrim behind it. |
+
+### Theming **(fork)**
+
+| File | Responsibility |
+|---|---|
+| `res/values-v31/`, `res/values-night-v31/` | Material You. Maps `bg`/`fg`/`accent` and the surface roles onto the `system_neutral*`/`system_accent*` palette, so the keyboard follows the wallpaper on Android 12+. The files in `values/` are the pre-31 fallback. |
+| `res/drawable/pack_selected.xml` | The filled container behind the selected pack icon. Selection used to be a colour filter over the sticker artwork, which recoloured the image. |
+| `res/drawable/sticker_ripple.xml` | Rounded ripple used by every tappable sticker and preview control. |
+| `res/drawable/preview_surface.xml` | Rounded surface behind the long-press preview. |
+
+Colour roles beyond `bg`/`fg`/`accent`: `surface_bar` (the pack bar), `selection_container`,
+`outline` (dividers), `ripple`, `scrim`. Add new roles in all four value directories or the v31
+variant silently falls back.
+
+## Support
+
+| File | Responsibility |
+|---|---|
+| `utilities/Toaster.kt` | Toast wrapper; also queues messages the importer emits. |
+| `utilities/StartLogger.kt` | xlog setup. Logs go to `filesDir/logs/`; `adb logcat -s EweSticker` is the fastest way to watch. |
+| `utilities/StickerClickListener.kt` | Callback interface the keyboard implements. |
+
+## Tests
+
+JVM unit tests only — `./gradlew testDebugUnitTest`. They cover the pieces with no Android
+dependencies, which is deliberately where the fiddly byte-level code lives.
+
+| File | Responsibility |
+|---|---|
+| `test/utilities/GifEncoderTest.kt` | Encoder round trips, transparency, palette budget, delays. |
+| `test/utilities/GifReader.kt` | A minimal GIF decoder used only by the tests, because `javax.imageio` is not on the Android unit-test classpath. |
+| `test/utilities/ImageResamplerTest.kt` | Flat colour, alpha bleed, gradients, up/downscale. |
+
+**Do not run `connectedAndroidTest`.** It uninstalls the app when it finishes, taking the imported
+library, the generated GIFs and every preference with it. There is an upstream `ScreenshotTest` in
+`androidTest/` that will do this if invoked.
+
+## Storage layout
+
+```
+filesDir/
+ stickers//.webp imported stickers, what the grid shows
+ stickers/__compatSticker__/ cached PNG fallbacks (never pruned as a pack)
+ variants//.gif GIF versions, generated or imported
+ import-manifest.tsv what the last import copied (fork)
+ logs/ xlog output
+```
+
+Preferences live in two files: `backup_prefs` (options, the `favourites` set, and the
+`libraryVersion` counter) and the default preferences (sticker directory, caches, `activePack`).
+
+Nothing in app storage is irreplaceable *except* `variants/`: a generated GIF has no counterpart in
+the sticker folder, so anything that deletes it must be able to regenerate it or must not run.
diff --git a/docs/gif-pipeline.md b/docs/gif-pipeline.md
new file mode 100644
index 0000000..5b983f9
--- /dev/null
+++ b/docs/gif-pipeline.md
@@ -0,0 +1,99 @@
+# The webp → GIF pipeline
+
+Apps that advertise `image/gif` but no webp mimetype — Instagram, Teams — can only show an animated
+sticker if it is handed to them as a GIF. Android provides neither half of that conversion: nothing
+in the public SDK yields individual frames of an animated webp (`ImageDecoder` gives you an
+`AnimatedImageDrawable` you can only play), and there is no GIF encoder at all.
+
+So both halves are implemented here. `tools/gif-variants.py` is the desktop reference and the
+quality bar; the on-device path is measured against it.
+
+## Decoding — `WebpAnimation.kt`
+
+Rather than bundling a second copy of libwebp (Fresco's `animated-webp` pulls its entire image
+pipeline in beside Coil, ~3MB), this walks the webp RIFF container directly and re-wraps each `ANMF`
+frame as a standalone one-image webp, which `BitmapFactory` decodes with the platform's own decoder.
+
+Frames are then composited onto a canvas honouring the container's blend and disposal flags.
+
+**Verified byte-exact**: re-muxed frame 0 of a real sticker decoded to a total absolute difference of
+**0** against the reference decoder.
+
+`isAnimated()` is the cheap probe used throughout. It walks chunk headers with `RandomAccessFile`,
+seeking past payloads. It previously searched a fixed 64-byte prefix for `ANIM`, which is wrong: an
+ICC profile between `VP8X` and `ANIM` pushes the marker out of the window, and the sticker is then
+silently treated as still.
+
+## Encoding — `GifEncoder.kt`
+
+GIF89a, no Android dependencies (hence unit-testable on the JVM):
+
+- **Median cut** per frame, then **Floyd–Steinberg** dithering — deliberately the same pairing Pillow
+ uses in the reference, so output stays comparable.
+- **Local colour table per frame**, since a palette chosen for the whole animation wastes entries.
+- **LZW** with the standard clear/EOI handling.
+
+### Two mistakes worth remembering
+
+**The LZW code-width off-by-one.** A decoder learns each dictionary entry one code *later* than the
+encoder creates it, so its table lags by one. Widening the code width when the encoder's own counter
+reaches the ceiling starts emitting wider codes one code before the decoder starts reading them, and
+the whole stream desynchronises. Every GIF the first version produced was undecodable. The condition
+is `nextCode > (1 shl codeSize)`, not `>= `.
+
+**The palette lookup cache.** Caching nearest-colour results on a 5-bits-per-channel key was ~4×
+faster but cost **1.3 dB**, because rounding the query throws away exactly the small differences
+dithering works in. Removed; the scan is exhaustive on purpose.
+
+### The sort that dominated everything
+
+`sortByChannel` was an insertion sort, justified by a comment claiming boxes are small. That is wrong
+for the first split, which covers *every distinct colour in the frame* — around 6,100 for a
+photographic sticker. Replaced with a counting sort over the 8-bit key.
+
+Both sorts are stable, so the box boundaries and resulting palette are identical. Measured on the
+same input:
+
+| | Time | Output |
+|---|---|---|
+| Insertion sort | 12,256 ms | 2,060,088 bytes |
+| Counting sort | 1,397 ms | 2,060,088 bytes |
+
+**Byte-identical**, ~10× faster. On device, regenerating all 53 animated stickers went from **247s to
+32s**.
+
+## Resampling — `ImageResampler.kt`
+
+Separable Lanczos-3 on ARGB, premultiplied so colour cannot bleed out of transparent pixels.
+`Bitmap.createScaledBitmap` only offers bilinear, which is too few taps to shrink cleanly and lands
+visibly short of the reference.
+
+## Quality against the reference
+
+Same sticker, 39 frames, 320px, PSNR measured against identical ground-truth frames:
+
+| | Size | PSNR |
+|---|---|---|
+| Desktop Pillow (the reference) | 1899 KB | 36.48 dB |
+| On-device, full pipeline | 2015 KB | 36.35 dB |
+| On-device, encoder only | 2011 KB | 36.37 dB |
+
+0.13 dB and 6% size apart — parity. That the full pipeline (36.35) matches encoder-only on
+Pillow-resized frames (36.37) shows the Lanczos resampler is effectively identical to Pillow's.
+
+## Settings
+
+`MAX_EDGE = 320`, `COLORS = 128`, matching the reference defaults. Full 512px is over 4MB per
+sticker, too heavy to send. These are compile-time constants; exposing them was considered and
+rejected as knob-adding whose only feedback loop is re-running a multi-minute conversion.
+
+## Conversion happens in two places
+
+- **At import**, for everything missing a variant, behind the "Make GIF versions" preference.
+- **At send time**, for one sticker, when a GIF-only app needs one that does not exist yet. Only
+ stickers actually sent get converted this way.
+
+Both go through `GifVariantGenerator.convert`, which writes a uniquely-named temp file and renames.
+A fixed `.part` name was unsafe once both paths existed: two conversions of the same sticker opened
+the same file, the second truncating what the first had written, and the first then renamed a
+NUL-holed GIF into place and sent it.
diff --git a/docs/import.md b/docs/import.md
new file mode 100644
index 0000000..a61d07c
--- /dev/null
+++ b/docs/import.md
@@ -0,0 +1,122 @@
+# Importing
+
+The importer copies a user-chosen SAF directory into app storage. Upstream it deleted both trees,
+re-copied everything, and regenerated every GIF; adding one pack cost a full rebuild and the keyboard
+was empty throughout. It is now incremental and non-destructive.
+
+Measured on a 358-sticker library with 53 animated stickers:
+
+| | Before | After |
+|---|---|---|
+| Reload, nothing changed | 32 s | ~0.3 s |
+| Add one 3-file pack | 358 files + 53 GIFs | 3 files |
+
+## The sequence
+
+1. **List** the tree with one `contentResolver.query` per directory, projecting document id, name,
+ mime, size and last-modified (`SourceListing`). `DocumentFile` caches nothing — every
+ `name`/`type`/`isFile` is its own Binder round trip, so the old walk cost thousands of them and
+ still did not yield the metadata an incremental import needs.
+2. **Full rebuild only**, delete the trees — *after* the listing has succeeded, never before.
+3. **Plan** sequentially: reject unsupported mimes, classify variants, enforce the per-pack cap. All
+ in one pass so the concurrent stage has no shared state to mutate.
+4. **Adopt** destinations already holding exactly the source's byte count into the manifest, so a
+ first run after upgrading costs nothing rather than a full re-copy.
+5. **Copy** what changed, eight at a time, into a temp file then rename.
+6. **Prune** what the source no longer has.
+7. **Save the manifest**, under `NonCancellable`, and forget entries the source no longer offers so
+ it cannot grow forever.
+8. **Generate** missing GIF versions, if enabled.
+
+## The manifest
+
+`filesDir/import-manifest.tsv`, one line per file: `pack/namesizemodified`. A file is
+up to date when its manifest entry matches the source *and* the destination is present at the same
+length.
+
+A provider reporting `0` for size or mtime is treated as **unknown**, never as a matching value —
+otherwise such files look unchanged forever.
+
+## Pruning is the dangerous part
+
+Deleting is the only thing here that can lose data that no source can restore: the generated GIFs
+have no counterpart in the sticker folder. Three guards:
+
+- **An incomplete listing never prunes.** `SourceListing` reports whether every directory could be
+ read. One unreadable directory is indistinguishable from the user having deleted that whole pack.
+- **A listing accounting for under half of what is imported never prunes.** Catches a stale grant or
+ an empty cursor, and points the user at Full re-import.
+- **Variants are judged differently from stickers.** A sticker is wanted if it is in the plan. A
+ variant is dropped only when its sticker is gone, or when that sticker's content actually changed
+ *and* a generation pass will follow to replace it.
+
+### Why staleness is not decided by timestamps
+
+The obvious rule — "variant older than its sticker means stale" — is wrong, because **copying
+rewrites the destination and moves its mtime forward**. Every sticker touched by a run then looks
+newer than its variant. On a first run, where nothing has a manifest entry and everything is
+re-copied, that means *every* variant.
+
+This shipped briefly and the log shows exactly it:
+
+```
+copied 358, removed 53
+Wrote 53 / 53 GIF variant(s)
+```
+
+All 53 GIFs deleted and rebuilt, for files whose content never changed. They came back only because
+GIF generation was enabled; with it off — the default — 66MB would have been lost with nothing to
+restore it. Staleness now comes from what the manifest says actually changed.
+
+## Deleting a sticker
+
+Long-pressing a sticker offers a bin, armed by the first tap and acted on by the second — it is
+armed for four seconds and then forgets. Two taps because this is the only place in the app that
+removes files from the user's own storage.
+
+Order matters, and the source goes first:
+
+1. **The source file**, via `SourceListing.removeDocument`. That returns `REMOVED`, `ABSENT` or
+ `FAILED`, and `FAILED` aborts the whole thing — treating "I could not find out" as "it is already
+ gone" would delete the imported copy of a sticker the next import brings straight back.
+2. **The sibling `.gif`** that `tools/gif-variants.py` writes next to a webp. Leaving it behind means
+ the next import no longer sees a webp of that name, so the orphan imports as a sticker in its own
+ right and the deleted sticker reappears as a GIF.
+3. The imported copy, the generated variant, the cached PNG fallback, and its entries in favourites
+ and recents.
+4. **The pack directory**, if it just emptied — from app storage, the variants tree, and the source.
+
+Then packs are reloaded and the content view is rebuilt. That last step matters: `jumpToPack` reuses
+an already-built all-packs list and only scrolls it, which is right when the selection changed and
+wrong after a deletion — the adapter keeps a cell for a file that has gone, and it draws as a blank
+gap instead of the grid closing up.
+
+### Removing a source directory is the riskiest thing here
+
+`DocumentsContract.deleteDocument` on a directory is **recursive**. `deletePackIfEmpty` therefore
+requires four independent things to agree before it runs:
+
+- a **complete** walk of the tree,
+- **no file anywhere in it** belonging to that pack,
+- **exactly one** directory of that name, matched on `MIME_TYPE_DIR` so a *file* named like a pack
+ cannot be deleted,
+- an emptiness check **asked twice** that rejects a cursor still reporting `EXTRA_LOADING`.
+
+A single zero-row cursor is not evidence. A provider that loads children asynchronously returns an
+empty, still-loading cursor right after a delete invalidates its cache — and taking that at face
+value would destroy a pack directory and every unimported file in it.
+
+## Full re-import
+
+A separate button, because the normal path no longer rebuilds anything. It lists first, then deletes
+both trees and the manifest, then proceeds as above. Ordering matters: deleting first and *then*
+discovering the source is unreadable — an unmounted card, a grant gone stale — destroys the library
+in precisely the situation the user reached for that button because something already looked wrong.
+
+## Concurrency
+
+`MainActivity` serialises every library-mutating operation behind one guard (`runLibraryOperation`):
+import, full re-import, generate and delete. All buttons in both cards are disabled for the duration
+and restored in a `finally`. Without it, a delete could wipe the variants tree underneath a running
+generation, and returning to the screen mid-generation re-enabled the buttons so a second generator
+would convert everything twice into the same files.
diff --git a/docs/sending.md b/docs/sending.md
new file mode 100644
index 0000000..8b10fdb
--- /dev/null
+++ b/docs/sending.md
@@ -0,0 +1,67 @@
+# Sending a sticker
+
+Everything here was established by reading what apps actually advertise on a real device, not from
+documentation. `adb logcat -s EweSticker` prints the advertised list on every field focus:
+
+```
+Connecting to com.whatsapp which supports
+[image/gif, video/x.looping_mp4, image/jpeg, image/jpg, image/png, image/webp.wasticker]
+```
+
+## The mechanism
+
+A keyboard sends media through `InputConnectionCompat.commitContent`, which hands the target app a
+content URI plus a mimetype. The target app decides what that is. It will only accept mimetypes it
+declared in `EditorInfo.contentMimeTypes`.
+
+An IME **cannot** produce a WhatsApp sticker-pack sticker; that is a separate API requiring a
+`StickerContentProvider` and an "Add to WhatsApp" flow. What it can do is described below.
+
+## What the four target apps advertise
+
+| App | Advertises | Result |
+|---|---|---|
+| `com.whatsapp`, `com.whatsapp.w4b` | `image/gif`, `video/x.looping_mp4`, `image/jpeg`, `image/jpg`, `image/png`, **`image/webp.wasticker`** | A real, animated sticker |
+| `com.instagram.android` | `image/png`, `image/jpeg`, `image/gif` | GIF animates; no sticker concept |
+| `com.microsoft.teams` | `image/png`, `image/gif`, `image/jpg`, `image/jpeg` | GIF animates |
+
+**Neither WhatsApp advertises plain `image/webp`.** It exposes the proprietary
+`image/webp.wasticker` instead, and only content committed under *that* mimetype is treated as a
+sticker. Commit a webp as `image/webp` and it fails the mimetype check entirely, falling through to
+a flattened PNG or a share sheet — which is what "my animated sticker arrives as a big photo" was.
+
+Instagram is a dead end for animation despite advertising `image/gif`: it accepts the commit and
+then declines to insert it, steering the user to its own GIPHY picker. Verified — the commit returns
+success and nothing arrives. Teams does render the GIF.
+
+## The ladder in `StickerSender.resolveCommit`
+
+In order:
+
+1. **Exact match**, including wildcards. `image/*` counts as accepting the original — without this,
+ an app advertising both `image/*` and `image/gif` would be handed a downscaled 128-colour GIF
+ when it would happily have taken the full-quality webp.
+2. **A vendor `image/webp.*` mimetype**, matched by prefix rather than hardcoding `wasticker`, so
+ another app's equivalent is picked up for free.
+3. **A GIF variant**, when the target takes `image/gif` and one exists in `variants/`.
+4. **On demand conversion** — if the sticker is animated, the target takes GIF, and no variant
+ exists, convert it now (`convertVariantOnDemand`). Serialised per destination so two taps do not
+ convert twice into the same file; failures are negatively cached.
+5. **PNG fallback**, at source resolution, cached in `stickers/__compatSticker__/`. Toasts when this
+ drops animation, distinguishing "this app cannot show animation" from "no GIF version yet".
+6. **Share sheet**, scoped to the target package, carrying the original file untouched.
+
+## Traps
+
+- **`FileProvider` roots.** `res/xml/file_paths.xml` must list every tree a committed file can come
+ from. Adding `variants/` was forgotten once and `getUriForFile` threw, killing the keyboard
+ mid-send. `doCommitContent` now catches that and degrades instead of crashing.
+- **A missing file still commits successfully.** `getUriForFile` does not check existence and
+ `commitContent` reports success, so a phantom path arrives as nothing at all. This is why
+ `createCompatSticker` verifies the file is non-empty before caching or returning it.
+- **The sender outlives its field.** An on-demand conversion takes seconds, during which the user
+ can leave. `ImageKeyboard` calls `abandon()` on the outgoing sender so a late completion cannot
+ commit into a dead connection or throw a share sheet over whatever the user moved on to.
+- **Nothing heavy on the main thread.** The PNG encode used to run inside a Coil `target` callback,
+ which Coil invokes on `Main.immediate` — a full-resolution encode on the thread drawing the
+ keyboard, on every cache miss.
diff --git a/tools/gif-variants.py b/tools/gif-variants.py
new file mode 100644
index 0000000..5659f42
--- /dev/null
+++ b/tools/gif-variants.py
@@ -0,0 +1,225 @@
+#!/usr/bin/env python3
+"""Generate GIF variants of animated webp stickers.
+
+WhatsApp accepts animated webp as a true sticker via its image/webp.wasticker
+mimetype, but Instagram and Teams advertise neither webp nor any sticker
+mimetype -- only image/gif. So for those apps the only way to keep a sticker
+moving is to hand them a GIF of the same artwork.
+
+This walks a sticker tree, finds the webp files that actually carry animation
+(an ANMF frame chunk), and writes a sibling .gif next to each one.
+EweSticker's importer treats a .gif sitting beside a same-named .webp as a
+format variant rather than as a separate sticker, so the keyboard picks
+whichever one the target app can render. Static stickers are left alone --
+there is nothing to animate.
+
+Run it again after downloading new packs; existing variants are skipped unless
+the source is newer, so it is cheap to re-run.
+
+Usage:
+ gif-variants.py convert a local directory in place
+ gif-variants.py --device [SERIAL] pull from, convert, push back to a phone
+ gif-variants.py --max-size 320 downscale to cap GIF file size
+"""
+
+from __future__ import annotations
+
+import argparse
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+try:
+ from PIL import Image
+except ImportError:
+ sys.exit("Pillow is required: pip install Pillow")
+
+DEVICE_STICKER_DIR = "/sdcard/Stickers"
+# GIF stores delays in centiseconds, so sub-10ms precision is unrepresentable.
+MIN_GIF_DELAY_MS = 20
+
+
+def iter_riff_chunks(data: bytes):
+ """Yield (fourcc, payload_offset, payload_len) for each chunk in a RIFF file."""
+ if data[:4] != b"RIFF" or data[8:12] != b"WEBP":
+ return
+ pos = 12
+ end = min(len(data), 8 + int.from_bytes(data[4:8], "little"))
+ while pos + 8 <= end:
+ fourcc = data[pos : pos + 4]
+ size = int.from_bytes(data[pos + 4 : pos + 8], "little")
+ yield fourcc, pos + 8, size
+ pos += 8 + size + (size & 1) # chunks are padded to even length
+
+
+def frame_durations(path: Path) -> list[int]:
+ """Per-frame durations in ms, read straight from the webp ANMF chunks.
+
+ An ANMF payload starts with frame x/y/width/height as 24-bit values, then a
+ 24-bit duration, so the duration sits at a fixed offset of 12 bytes.
+ """
+ data = path.read_bytes()
+ durations = []
+ for fourcc, offset, _size in iter_riff_chunks(data):
+ if fourcc == b"ANMF":
+ durations.append(int.from_bytes(data[offset + 12 : offset + 15], "little"))
+ return durations
+
+
+def is_animated(path: Path) -> bool:
+ return bool(frame_durations(path))
+
+
+def quantize(frame: Image.Image, colors: int, has_alpha: bool) -> Image.Image:
+ """Convert an RGBA frame to a palettised frame GIF can store.
+
+ GIF transparency is a single palette index, so when the artwork has an alpha
+ channel one slot is reserved for it and every pixel below the halfway point
+ is snapped to fully transparent. That hard cutoff is what produces fringing
+ on soft edges; it is a limit of the format, not of this conversion.
+ """
+ if not has_alpha:
+ return frame.convert("RGB").quantize(colors=colors)
+
+ palettised = frame.convert("RGB").quantize(colors=min(colors, 255))
+ transparent = frame.getchannel("A").point(lambda a: 255 if a < 128 else 0)
+ palettised.paste(255, transparent)
+ return palettised
+
+
+def convert(src: Path, dest: Path, max_size: int, colors: int, every: int) -> tuple[int, int]:
+ """Write an animated GIF of src to dest. Returns (frame count, byte size)."""
+ durations = frame_durations(src)
+ image = Image.open(src)
+
+ frames: list[Image.Image] = []
+ kept_durations: list[int] = []
+ has_alpha = False
+ for index in range(0, getattr(image, "n_frames", 1), every):
+ image.seek(index)
+ frame = image.convert("RGBA")
+ if frame.getchannel("A").getextrema()[0] < 255:
+ has_alpha = True
+ if max(frame.size) > max_size:
+ scale = max_size / max(frame.size)
+ new_size = (round(frame.width * scale), round(frame.height * scale))
+ frame = frame.resize(new_size, Image.LANCZOS)
+ frames.append(frame)
+ # Dropped frames' time has to be absorbed by the frame that replaces them,
+ # otherwise decimating the animation also speeds it up.
+ window = durations[index : index + every]
+ kept_durations.append(sum(window) if window else 0)
+
+ palettised = [quantize(frame, colors, has_alpha) for frame in frames]
+ delays = [max(d, MIN_GIF_DELAY_MS) for d in kept_durations if d] or [100] * len(palettised)
+
+ save_options = {
+ "save_all": True,
+ "append_images": palettised[1:],
+ "duration": delays,
+ "loop": 0,
+ "disposal": 2, # restore to background between frames
+ "optimize": False,
+ }
+ if has_alpha:
+ save_options["transparency"] = 255
+
+ palettised[0].save(dest, **save_options)
+ return len(palettised), dest.stat().st_size
+
+
+def convert_tree(root: Path, max_size: int, colors: int, every: int, force: bool) -> int:
+ converted = 0
+ skipped_static = 0
+ for src in sorted(root.rglob("*.webp")):
+ dest = src.with_suffix(".gif")
+ if not is_animated(src):
+ skipped_static += 1
+ continue
+ if dest.exists() and not force and dest.stat().st_mtime >= src.stat().st_mtime:
+ print(f" = {src.parent.name}/{src.name} (variant up to date)")
+ continue
+ try:
+ frames, size = convert(src, dest, max_size, colors, every)
+ except Exception as exc: # a single bad sticker should not abort the run
+ print(f" ! {src.parent.name}/{src.name}: {exc}", file=sys.stderr)
+ continue
+ print(f" + {src.parent.name}/{src.name} -> {frames} frames, {size // 1024}KB")
+ converted += 1
+ print(f"\n{converted} GIF variant(s) written; {skipped_static} static sticker(s) left alone.")
+ return converted
+
+
+def adb(serial: str | None, *args: str) -> str:
+ command = ["adb"]
+ if serial:
+ command += ["-s", serial]
+ result = subprocess.run(command + list(args), capture_output=True, text=True)
+ if result.returncode != 0:
+ sys.exit(f"adb {' '.join(args)} failed: {result.stderr.strip()}")
+ return result.stdout
+
+
+def run_on_device(serial: str | None, remote: str, max_size: int, colors: int,
+ every: int, force: bool) -> None:
+ workdir = Path(tempfile.mkdtemp(prefix="gif-variants-"))
+ try:
+ print(f"Pulling {remote} ...")
+ adb(serial, "pull", remote, str(workdir / "Stickers"))
+ local = workdir / "Stickers"
+ if not local.is_dir():
+ sys.exit(f"nothing pulled from {remote}")
+
+ if not convert_tree(local, max_size, colors, every, force):
+ print("Nothing to push.")
+ return
+
+ print("\nPushing GIF variants back ...")
+ for gif in sorted(local.rglob("*.gif")):
+ target = f"{remote}/{gif.parent.name}/{gif.name}"
+ adb(serial, "push", str(gif), target)
+ print(f" -> {target}")
+ print("\nDone. Re-import your sticker folder in EweSticker to pick these up.")
+ finally:
+ shutil.rmtree(workdir, ignore_errors=True)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("directory", nargs="?", type=Path, help="local sticker tree to convert")
+ parser.add_argument("--device", nargs="?", const="", metavar="SERIAL",
+ help="operate on a connected phone over adb")
+ parser.add_argument("--remote", default=DEVICE_STICKER_DIR,
+ help=f"sticker directory on the phone (default: {DEVICE_STICKER_DIR})")
+ # GIF is far bulkier than webp for the same artwork, so the defaults trade a
+ # little fidelity for a file size that is sane to send in a chat: a 512px
+ # 39-frame sticker is 4.6MB at full size and 1.9MB at these settings.
+ parser.add_argument("--max-size", type=int, default=320,
+ help="cap the longest edge, downscaling to shrink the GIF (default: 320)")
+ parser.add_argument("--colors", type=int, default=128,
+ help="palette size per frame, 2-256 (default: 128)")
+ parser.add_argument("--every", type=int, default=1, metavar="N",
+ help="keep only every Nth frame; 2 roughly halves the size (default: 1)")
+ parser.add_argument("--force", action="store_true", help="rewrite variants that already exist")
+ args = parser.parse_args()
+
+ if not 2 <= args.colors <= 256:
+ parser.error("--colors must be between 2 and 256")
+ if args.every < 1:
+ parser.error("--every must be 1 or greater")
+
+ if args.device is not None:
+ run_on_device(args.device or None, args.remote.rstrip("/"), args.max_size,
+ args.colors, args.every, args.force)
+ elif args.directory:
+ if not args.directory.is_dir():
+ sys.exit(f"not a directory: {args.directory}")
+ convert_tree(args.directory, args.max_size, args.colors, args.every, args.force)
+ else:
+ parser.error("pass a directory or --device")
+
+
+if __name__ == "__main__":
+ main()