commit cb4ac000bc76888e2b331e530aeded1f819fe74c
Author: Kieran BW <41634689+FredHappyface@users.noreply.github.com>
Date: Fri Jun 11 20:20:42 2021 +0100
first release
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..aa724b7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..fb7f4a8
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 0000000..440480e
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
new file mode 100644
index 0000000..0380d8d
--- /dev/null
+++ b/.idea/jarRepositories.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..860da66
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
new file mode 100644
index 0000000..797acea
--- /dev/null
+++ b/.idea/runConfigurations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 0000000..ef60d96
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,55 @@
+plugins {
+ id 'com.android.application'
+ id 'kotlin-android'
+}
+
+android {
+ compileSdkVersion 30
+ buildToolsVersion "30.0.3"
+
+ defaultConfig {
+ applicationId "com.fredhappyface.whoosticker"
+ minSdkVersion 28
+ targetSdkVersion 30
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ android.applicationVariants.all { variant ->
+ variant.outputs.each { output ->
+ output.outputFileName = output.outputFileName.replace(".apk", "_" + defaultConfig.versionName + "_" + LocalDate.now() + ".apk")
+ }
+ }
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+}
+
+dependencies {
+
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
+ implementation 'androidx.core:core-ktx:1.5.0'
+ implementation 'androidx.appcompat:appcompat:1.3.0'
+ implementation 'com.google.android.material:material:1.3.0'
+ implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
+ implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
+ implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
+ implementation 'androidx.preference:preference-ktx:1.1.1'
+ testImplementation 'junit:junit:4.13.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.2'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
+
+ implementation "com.github.penfeizhou.android.animation:apng:2.10.0"
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/fredhappyface/whoosticker/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/fredhappyface/whoosticker/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..0554e6f
--- /dev/null
+++ b/app/src/androidTest/java/com/fredhappyface/whoosticker/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.fredhappyface.whoosticker
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.fredhappyface.whoosticker", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..9ad4911
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..55c35b1
Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/com/fredhappyface/whoosticker/ImageKeyboard.kt b/app/src/main/java/com/fredhappyface/whoosticker/ImageKeyboard.kt
new file mode 100644
index 0000000..ffaca03
--- /dev/null
+++ b/app/src/main/java/com/fredhappyface/whoosticker/ImageKeyboard.kt
@@ -0,0 +1,329 @@
+package com.fredhappyface.whoosticker
+
+import android.content.ClipDescription
+import android.content.SharedPreferences
+import android.graphics.Bitmap
+import android.graphics.ImageDecoder
+import android.graphics.drawable.AnimatedImageDrawable
+import android.graphics.drawable.Drawable
+import android.inputmethodservice.InputMethodService
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputMethodManager
+import android.widget.ImageButton
+import android.widget.LinearLayout
+import android.widget.RelativeLayout
+import android.widget.Toast
+import androidx.cardview.widget.CardView
+import androidx.core.content.FileProvider
+import androidx.core.content.res.ResourcesCompat
+import androidx.core.view.inputmethod.EditorInfoCompat
+import androidx.core.view.inputmethod.InputConnectionCompat
+import androidx.core.view.inputmethod.InputContentInfoCompat
+import androidx.preference.PreferenceManager
+import com.github.penfeizhou.animation.apng.APNGDrawable
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.util.*
+
+class ImageKeyboard : InputMethodService() {
+ // Attributes
+ private lateinit var supportedMimes: MutableMap
+ private var loadedPacks = HashMap()
+ private var imageContainer: LinearLayout? = null
+ private var packContainer: LinearLayout? = null
+ private lateinit var internalDir: File
+ private var iconsPerRow = 0
+ private var iconSize = 0
+ private lateinit var sharedPreferences: SharedPreferences
+
+ /**
+ * Adds a back button as a PackCard to keyboard that shows the InputMethodPicker
+ */
+ private fun addBackButtonToContainer() {
+ val packCard = layoutInflater.inflate(R.layout.pack_card, packContainer, false)
+ val backButton = packCard.findViewById(R.id.ib3)
+ val icon =
+ ResourcesCompat.getDrawable(resources, R.drawable.tabler_icon_arrow_back_white, null)
+ backButton.setImageDrawable(icon)
+ backButton.setOnClickListener {
+ val inputMethodManager = applicationContext
+ .getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
+ inputMethodManager.showInputMethodPicker()
+ }
+ packContainer!!.addView(packCard)
+ }
+
+ /**
+ * Adds a pack card to the keyboard from a StickerPack
+ *
+ * @param pack: StickerPack - the sticker pack to add
+ */
+ private fun addPackToContainer(pack: StickerPack) {
+ val packCard = layoutInflater.inflate(R.layout.pack_card, packContainer, false)
+ val packButton = packCard.findViewById(R.id.ib3)
+ setPackButtonImage(pack, packButton)
+ packButton.tag = pack
+ packButton.setOnClickListener { view: View ->
+ imageContainer!!.removeAllViewsInLayout()
+ recreateImageContainer(view.tag as StickerPack)
+ }
+ packContainer!!.addView(packCard)
+ }
+
+ /**
+ * In the event that a mimetype is unsupported by a InputConnectionCompat (looking at you, Signal)
+ * Create a temporary png and send that. In the event that png is not supported, create a toast as before
+ *
+ * @param file: File
+ */
+ private fun doFallbackCommitContent(file: File) {
+ // PNG might not be supported so fallback to toast
+ if (supportedMimes[".png"] == null) {
+ Toast.makeText(
+ applicationContext, Utils.getFileExtension(file.name) +
+ " not supported here.", Toast.LENGTH_LONG
+ ).show()
+ return
+ }
+ // Create a new compatSticker and convert the sticker to png
+ val compatSticker = File(filesDir, "stickers/__compatSticker__/__compatSticker__.png")
+ compatSticker.parentFile?.mkdirs() // Protect against null pointer exception
+ try {
+ ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
+ .compress(Bitmap.CompressFormat.PNG, 90, FileOutputStream(compatSticker))
+ } catch (ignore: IOException) {
+ }
+ // Send the compatSticker!
+ doCommitContent("description", "image/png", compatSticker)
+ }
+
+ /**
+ * @param description: String
+ * @param mimeType: String
+ * @param file: File
+ */
+ private fun doCommitContent(
+ description: String, mimeType: String,
+ file: File
+ ) {
+ val contentUri = FileProvider.getUriForFile(this, AUTHORITY, file)
+ val flag = InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION
+ val inputContentInfoCompat = InputContentInfoCompat(
+ contentUri,
+ ClipDescription(description, arrayOf(mimeType)),
+ null
+ )
+ InputConnectionCompat.commitContent(
+ currentInputConnection, currentInputEditorInfo, inputContentInfoCompat,
+ flag, null
+ )
+ }
+
+ /**
+ * Apply a sticker file to the image button
+ *
+ * @param sticker: File - the file object representing the sticker
+ * @param btn: ImageButton - the button
+ */
+ private fun setStickerButtonImage(sticker: File, btn: ImageButton) {
+ val sName = sticker.name
+ // Create drawable from file
+ var drawable: Drawable? = null
+ try {
+ drawable = ImageDecoder.decodeDrawable(ImageDecoder.createSource(sticker))
+ } catch (ignore: IOException) {
+ }
+ if (sName.contains(".png") || sName.contains(".apng")) {
+ drawable = APNGDrawable.fromFile(sticker.absolutePath)
+ drawable!!.setAutoPlay(false)
+ drawable.start()
+ }
+ // Disable animations?
+ if (drawable is AnimatedImageDrawable && !sharedPreferences.getBoolean(
+ "disable_animations",
+ false
+ )
+ ) {
+ drawable.start()
+ }
+ if (drawable is APNGDrawable && sharedPreferences.getBoolean("disable_animations", false)) {
+ drawable.stop()
+ }
+ // Apply
+ btn.setImageDrawable(drawable)
+ }
+
+ /**
+ * Apply a sticker the the pack icon (imagebutton)
+ *
+ * @param pack: StickerPack - the stickerpack to grab the pack icon from
+ * @param btn: ImageButton - the button
+ */
+ private fun setPackButtonImage(pack: StickerPack, btn: ImageButton) {
+ setStickerButtonImage(pack.thumbSticker, btn)
+ }
+
+ /**
+ * Check if the sticker is supported by the receiver
+ *
+ * @param editorInfo: EditorInfo - the editor/ receiver
+ * @param mimeType: String - the image mimetype
+ * @return boolean - is the mimetype supported?
+ */
+ private fun isCommitContentSupported(
+ editorInfo: EditorInfo?, mimeType: String
+ ): Boolean {
+ if (editorInfo == null) {
+ return false
+ }
+ currentInputConnection ?: return false
+ if (!validatePackageName(editorInfo)) {
+ return false
+ }
+ val supportedMimeTypes = EditorInfoCompat.getContentMimeTypes(editorInfo)
+ for (supportedMimeType in supportedMimeTypes) {
+ if (ClipDescription.compareMimeTypes(mimeType, supportedMimeType)) {
+ return true
+ }
+ }
+ return false
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ sharedPreferences = PreferenceManager.getDefaultSharedPreferences(baseContext)
+ iconsPerRow = sharedPreferences.getInt("iconsPerRow", 3)
+ iconSize = sharedPreferences.getInt("iconSize", 160)
+ reloadPacks()
+ }
+
+ override fun onCreateInputView(): View {
+ val keyboardLayout =
+ layoutInflater.inflate(R.layout.keyboard_layout, null) as RelativeLayout
+ packContainer = keyboardLayout.findViewById(R.id.packContainer)
+ imageContainer = keyboardLayout.findViewById(R.id.imageContainer)
+ imageContainer?.layoutParams?.height = (iconSize * iconsPerRow * 1.4).toInt()
+ recreatePackContainer()
+ return keyboardLayout
+ }
+
+ override fun onEvaluateFullscreenMode(): Boolean {
+ // In full-screen mode the inserted content is likely to be hidden by the IME. Hence in this
+ // sample we simply disable full-screen mode.
+ return false
+ }
+
+ override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
+ supportedMimes = Utils.getSupportedMimes()
+ var allSupported = true
+ val mimesToCheck = supportedMimes.keys.toTypedArray()
+ for (s in mimesToCheck) {
+ val mimeSupported = isCommitContentSupported(info, supportedMimes[s]!!)
+ allSupported = allSupported && mimeSupported
+ if (!mimeSupported) {
+ supportedMimes.remove(s)
+ }
+ }
+ if (!allSupported) {
+ Toast.makeText(
+ applicationContext,
+ "One or more image formats not supported here. Some stickers may not send correctly.",
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
+
+ private fun recreateImageContainer(pack: StickerPack) {
+ val imagesDir = File(filesDir, "stickers/$pack")
+ imagesDir.mkdirs()
+ imageContainer!!.removeAllViewsInLayout()
+ var imageContainerColumn = layoutInflater.inflate(
+ R.layout.image_container_column,
+ imageContainer,
+ false
+ ) as LinearLayout
+ val stickers = pack.stickerList
+ for (i in stickers.indices) {
+ if (i % iconsPerRow == 0) {
+ imageContainerColumn = layoutInflater.inflate(
+ R.layout.image_container_column,
+ imageContainer,
+ false
+ ) as LinearLayout
+ }
+ val imageCard = layoutInflater.inflate(
+ R.layout.sticker_card,
+ imageContainerColumn,
+ false
+ ) as CardView
+ val imgButton = imageCard.findViewById(R.id.ib3)
+ imgButton.layoutParams.height = iconSize
+ imgButton.layoutParams.width = iconSize
+ setStickerButtonImage(stickers[i], imgButton)
+ imgButton.tag = stickers[i]
+ imgButton.setOnClickListener { view: View ->
+ val file = view.tag as File
+ val stickerType = supportedMimes[Utils.getFileExtension(file.name)]
+ if (stickerType == null) {
+ doFallbackCommitContent(file)
+ return@setOnClickListener
+ }
+ doCommitContent(file.name, stickerType, file)
+ }
+ imageContainerColumn.addView(imageCard)
+ if (i % iconsPerRow == 0) {
+ imageContainer!!.addView(imageContainerColumn)
+ }
+ }
+ }
+
+ private fun recreatePackContainer() {
+ packContainer!!.removeAllViewsInLayout()
+ // Back button
+ if (sharedPreferences.getBoolean("showBackButton", false)) {
+ addBackButtonToContainer()
+ }
+ // Packs
+ val sortedPackNames = loadedPacks.keys.toTypedArray()
+ Arrays.sort(sortedPackNames)
+ for (sortedPackName in sortedPackNames) {
+ addPackToContainer(loadedPacks[sortedPackName]!!)
+ }
+ if (sortedPackNames.isNotEmpty()) {
+ recreateImageContainer(loadedPacks[sortedPackNames[0]]!!)
+ }
+ }
+
+ private fun reloadPacks() {
+ loadedPacks = HashMap()
+ internalDir = File(filesDir, "stickers")
+ val packs = internalDir.listFiles { obj: File -> obj.isDirectory }
+ if (packs != null) {
+ for (file in packs) {
+ val pack = StickerPack(file)
+ if (pack.stickerList.isNotEmpty()) {
+ loadedPacks[file.name] = pack
+ }
+ }
+ }
+ val baseStickers = internalDir.listFiles { obj: File -> obj.isFile }
+ if (baseStickers != null && baseStickers.isNotEmpty()) {
+ loadedPacks[""] = StickerPack(internalDir)
+ }
+ }
+
+ private fun validatePackageName(editorInfo: EditorInfo?): Boolean {
+ if (editorInfo == null) {
+ return false
+ }
+ val packageName = editorInfo.packageName
+ return packageName != null
+ }
+
+ companion object {
+ // Constants
+ private const val AUTHORITY = "com.fredhappyface.whoosticker.inputcontent"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/fredhappyface/whoosticker/MainActivity.kt b/app/src/main/java/com/fredhappyface/whoosticker/MainActivity.kt
new file mode 100644
index 0000000..f71c5b2
--- /dev/null
+++ b/app/src/main/java/com/fredhappyface/whoosticker/MainActivity.kt
@@ -0,0 +1,266 @@
+package com.fredhappyface.whoosticker
+
+import android.content.Intent
+import android.content.SharedPreferences
+import android.net.Uri
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.View
+import android.widget.*
+import android.widget.SeekBar.OnSeekBarChangeListener
+import androidx.appcompat.app.AppCompatActivity
+import androidx.documentfile.provider.DocumentFile
+import androidx.preference.PreferenceManager
+import java.io.File
+import java.io.IOException
+import java.nio.file.Files
+import java.util.*
+import java.util.concurrent.Executors
+
+class MainActivity : AppCompatActivity() {
+ private val chooseStickerDir = 62519
+ private val supportedMimes = Utils.getSupportedMimes()
+ lateinit var sharedPreferences: SharedPreferences
+
+ /**
+ * For each sticker, check if it is in a compatible file format with WhooSticker
+ *
+ * @param sticker sticker to check compatibility with WhooSticker for
+ * @return true if supported image type
+ */
+ private fun canImportSticker(sticker: DocumentFile): Boolean {
+ val mimesToCheck = ArrayList(supportedMimes.keys)
+ return !(sticker.isDirectory ||
+ !mimesToCheck.contains(sticker.name?.let { Utils.getFileExtension(it) }))
+ }
+
+ /**
+ * Called on button press to choose a new directory
+ *
+ * @param view: View
+ */
+ fun chooseDir(view: View) {
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
+ startActivityForResult(intent, chooseStickerDir)
+ }
+
+ /**
+ * Delete everything from input File
+ *
+ * @param fileOrDirectory File to start deleting from
+ */
+ private fun deleteRecursive(fileOrDirectory: File) {
+ if (fileOrDirectory.isDirectory) {
+ for (child in Objects.requireNonNull(fileOrDirectory.listFiles())) {
+ deleteRecursive(child)
+ }
+ }
+ fileOrDirectory.delete()
+ }
+
+ /**
+ * Copies images from pack directory by calling importSticker() on all of them
+ *
+ * @param pack source pack
+ */
+ private fun importPack(pack: DocumentFile): Int {
+ var stickersInPack = 0
+ val stickers = pack.listFiles()
+ for (sticker in stickers) {
+ stickersInPack += importSticker(sticker, pack.name + "/")
+ }
+ return stickersInPack
+ }
+
+ /**
+ * Copies stickers from source to internal storage
+ *
+ * @param sticker sticker to copy over
+ * @param pack the pack which the sticker belongs to
+ */
+ private fun importSticker(sticker: DocumentFile, pack: String): Int {
+ if (!canImportSticker(sticker)) {
+ return 0
+ }
+ val destSticker = File(filesDir, "stickers/" + pack + sticker.name)
+ destSticker.parentFile?.mkdirs()
+ try {
+ val inputStream = contentResolver.openInputStream(sticker.uri)
+ Files.copy(inputStream, destSticker.toPath())
+ inputStream!!.close()
+ } catch (e: IOException) {
+ e.printStackTrace()
+ }
+ return 1
+ }
+
+ /**
+ * Import files from storage to internal directory
+ */
+ private fun importStickers() {
+ //Use worker thread because this takes several seconds
+ val executor = Executors.newSingleThreadExecutor()
+ val handler = Handler(Looper.getMainLooper())
+ Toast.makeText(
+ applicationContext,
+ "Starting import. You will not be able to reselect directory until finished. This might take a bit!",
+ Toast.LENGTH_LONG
+ ).show()
+ val button = findViewById