package social.grain import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Path import android.graphics.drawable.AdaptiveIconDrawable import android.os.Build import androidx.core.graphics.drawable.DrawableCompat import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Assume.assumeTrue import org.junit.Test import org.junit.runner.RunWith import java.io.File /** * The launcher icon's monochrome layer, which is what Android 13+ tints when * the reader turns on themed icons. * * Worth a test because nothing else catches its absence: an adaptive icon with * no `` is perfectly valid, so the build stays green and the app * simply opts out of themed icons — silently, and only on the home screens of * readers who have the setting on. Neither the phone this was developed on nor * the emulator image exposes that setting, so the layer is checked here through * the same API the launcher uses instead. */ @RunWith(AndroidJUnit4::class) class LauncherIconTest { private val context = InstrumentationRegistry.getInstrumentation().targetContext private fun monochrome() = (context.packageManager.getApplicationIcon(context.packageName) as AdaptiveIconDrawable).monochrome @Test fun launcherIconCarriesAMonochromeLayer() { assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) assertNotNull("Adaptive icon has no layer", monochrome()) } /** * A layer that draws nothing would satisfy the check above while still * giving the launcher a blank circle, so this renders it and counts pixels. * * The geometry mirrors the launcher's: the layer is 108 units wide and only * its centre 72 survive the mask, so it is drawn 1.5x the output size and * offset by a quarter of it. The wordmark is deliberately wide and short, * hence the low bar on coverage — it inks well under a tenth of the circle. */ @Test fun monochromeLayerActuallyDrawsSomething() { assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) val layer = requireNotNull(monochrome()) val size = 192 val scaled = (size * 108f / 72f).toInt() val offset = -(scaled - size) / 2 val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) Canvas(bitmap).apply { clipPath(Path().apply { addCircle(size / 2f, size / 2f, size / 2f, Path.Direction.CW) }) drawColor(BACKGROUND) DrawableCompat.setTint(layer, FOREGROUND) layer.setBounds(offset, offset, offset + scaled, offset + scaled) layer.draw(this) } val pixels = IntArray(size * size).also { bitmap.getPixels(it, 0, size, 0, 0, size, size) } val inked = pixels.count { it == FOREGROUND } assertTrue("Monochrome layer drew $inked pixels; it is effectively blank", inked > 500) // Dumped so the icon can be eyeballed on a device whose launcher has no // themed-icon setting. Internal storage rather than getExternalFilesDir, // because scoped storage keeps adb out of Android/data on the shell side // while `run-as` reaches a debuggable app's own directory: // // adb shell run-as social.grain.debug \ // cat files/launcher_icon_monochrome.png > icon.png File(context.filesDir, "launcher_icon_monochrome.png").outputStream() .use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } } private companion object { /** * The two colours AOSP's `ThemedIconDrawable` picks in a dark theme, so * the dump matches what a launcher would actually put on the wallpaper. */ val BACKGROUND = Color.parseColor("#FF2B3231") val FOREGROUND = Color.parseColor("#FF00FDF0") } }