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