1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
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 `<monochrome>` 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 <monochrome> 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")
}
}