diff --git a/appexample/src/main/AndroidManifest.xml b/appexample/src/main/AndroidManifest.xml index 8aabd22..8522405 100644 --- a/appexample/src/main/AndroidManifest.xml +++ b/appexample/src/main/AndroidManifest.xml @@ -5,7 +5,6 @@ android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" - android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Gradlepluginlinthandroid"> - - - - - \ No newline at end of file diff --git a/appexample/src/main/res/mipmap-hdpi/ic_launcher.png b/appexample/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..e10003f Binary files /dev/null and b/appexample/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/appexample/src/main/res/mipmap-hdpi/ic_launcher.webp b/appexample/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78..0000000 Binary files a/appexample/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ diff --git a/appexample/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/appexample/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d..0000000 Binary files a/appexample/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ diff --git a/appexample/src/main/res/mipmap-mdpi/ic_launcher.png b/appexample/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..24a4d6f Binary files /dev/null and b/appexample/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/appexample/src/main/res/mipmap-mdpi/ic_launcher.webp b/appexample/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d6..0000000 Binary files a/appexample/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/appexample/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/appexample/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 62b611d..0000000 Binary files a/appexample/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ diff --git a/appexample/src/main/res/mipmap-xhdpi/ic_launcher.png b/appexample/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..a0ea661 Binary files /dev/null and b/appexample/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/appexample/src/main/res/mipmap-xhdpi/ic_launcher.webp b/appexample/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a307..0000000 Binary files a/appexample/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ diff --git a/appexample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/appexample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a695..0000000 Binary files a/appexample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/appexample/src/main/res/mipmap-xxhdpi/ic_launcher.png b/appexample/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..e102642 Binary files /dev/null and b/appexample/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/appexample/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/appexample/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77..0000000 Binary files a/appexample/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/appexample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/appexample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9287f50..0000000 Binary files a/appexample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/appexample/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/appexample/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..46d576a Binary files /dev/null and b/appexample/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/appexample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/appexample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d642..0000000 Binary files a/appexample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/appexample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/appexample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9126ae3..0000000 Binary files a/appexample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/plugin-build/plugin/src/main/java/ch/ubique/linth/IconTask.kt b/plugin-build/plugin/src/main/java/ch/ubique/linth/IconTask.kt index ec60c51..c7d9f79 100644 --- a/plugin-build/plugin/src/main/java/ch/ubique/linth/IconTask.kt +++ b/plugin-build/plugin/src/main/java/ch/ubique/linth/IconTask.kt @@ -1,8 +1,13 @@ package ch.ubique.linth +import ch.ubique.linth.common.IconUtils import ch.ubique.linth.common.getMergedManifestFile +import com.android.build.gradle.BaseExtension import org.gradle.api.DefaultTask +import org.gradle.api.artifacts.ProjectDependency import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Optional import org.gradle.api.tasks.TaskAction import java.io.File import kotlin.math.max @@ -15,12 +20,17 @@ abstract class IconTask : DefaultTask() { } @get:Input - abstract var flavors: Set + abstract var flavorAndBuildType: Set> + + @get:InputFile + @get:Optional + abstract var targetWebIcon: File? @TaskAction fun iconAction() { val moduleDir = File(project.rootDir, project.name) val buildDir = project.layout.buildDirectory.asFile.get() + val targetWebIcon = targetWebIcon val gradleLastModified = max( File(moduleDir, "build.gradle").lastModified(), @@ -29,7 +39,7 @@ abstract class IconTask : DefaultTask() { val generatedResDir = File("$buildDir/generated/res/launcher-icon/") - flavors.forEach { flavor -> + flavorAndBuildType.forEach { (flavor, buildType) -> // get banner label val defaultLabelEnabled = false//android.defaultConfig.launcherIconLabelEnabled val flavorLabelEnabled = true//flavor.launcherIconLabelEnabled @@ -40,8 +50,81 @@ abstract class IconTask : DefaultTask() { null } - val manifestFile = project.getMergedManifestFile(flavor, "debug") - println("ManifestFile: ${manifestFile.path}") + val manifestFile = project.getMergedManifestFile(flavor, buildType) + + val androidModules: List = project.configurations + .asSequence() + .flatMap { it.dependencies } + .filterIsInstance() + .map { it.dependencyProject } + .distinct() + .mapNotNull { it.extensions.findByType(BaseExtension::class.java) } + .toList() + + val resDirs: List = androidModules + .flatMap { + listOfNotNull( + it.sourceSets.findByName(flavor), + it.sourceSets.findByName("main") + ) + } + .flatMap { it.res.srcDirs } + .filter { !it.path.contains("generated") } + + val allIcons = IconUtils.findIcons(resDirs, manifestFile) + + if (targetWebIcon != null) { + targetWebIcon.delete() + + // Search for web icon source + val webIconSource = ( + (File(moduleDir, "src/${flavor.lowercase()}").listFiles() ?: arrayOf()) + + (File(moduleDir, "src/main").listFiles() ?: arrayOf()) + + (moduleDir.listFiles() ?: arrayOf()) + ).find { it.name.matches(Regex(".*(web|playstore|512)\\.(png|webp)")) } + ?: IconUtils.findLargestIcon(allIcons) // Fallback if not found + + if (webIconSource == null) { + println("$name: web icon source not found") + } else if (bannerLabel.isNullOrEmpty()) { + // No label, so we only copy the sourceIcon + println("$name: web icon: $webIconSource") + webIconSource.copyTo(targetWebIcon, overwrite = true) + } else { + println("$name: web icon: $webIconSource") + IconUtils.drawLabel(webIconSource, targetWebIcon, bannerLabel, adaptive = false) + } + } + + if (bannerLabel.isNullOrEmpty()) { + // No label + println("$name: skipped icon labelling") + return + } + + allIcons.forEach iconsForEach@{ original -> + val resTypeName = original.parentFile.name + val originalBaseName = original.name.substringBefore(".") + val targetDir = File("${generatedResDir}/${flavor.lowercase()}/$resTypeName") + + val modified = targetDir.listFiles { file -> + file.name.matches(Regex("$originalBaseName\\.[^.]+")) + }?.firstOrNull() + + if (modified != null + && original.lastModified() <= modified.lastModified() + && gradleLastModified <= modified.lastModified() + ) { + return@iconsForEach + } else { + println("bannerlabel is $bannerLabel") + val target = File(targetDir, original.name) + targetDir.mkdirs() + original.copyTo(target, overwrite = true) + IconUtils.createLayeredLabel(target, bannerLabel, originalBaseName.endsWith("_foreground")) + } + + } } } diff --git a/plugin-build/plugin/src/main/java/ch/ubique/linth/InjectMetaIntoManifestTask.kt b/plugin-build/plugin/src/main/java/ch/ubique/linth/InjectMetaIntoManifestTask.kt index 0a4c832..4d0d4a4 100644 --- a/plugin-build/plugin/src/main/java/ch/ubique/linth/InjectMetaIntoManifestTask.kt +++ b/plugin-build/plugin/src/main/java/ch/ubique/linth/InjectMetaIntoManifestTask.kt @@ -34,7 +34,6 @@ abstract class InjectMetaIntoManifestTask : DefaultTask() { @get:Input abstract var flavorAndBuildType: Set> - @TaskAction fun injectMetadataIntoManifest() { flavorAndBuildType.forEach { (flavor, buildType) -> diff --git a/plugin-build/plugin/src/main/java/ch/ubique/linth/LinthPlugin.kt b/plugin-build/plugin/src/main/java/ch/ubique/linth/LinthPlugin.kt index 58b0c5c..5d0c12c 100644 --- a/plugin-build/plugin/src/main/java/ch/ubique/linth/LinthPlugin.kt +++ b/plugin-build/plugin/src/main/java/ch/ubique/linth/LinthPlugin.kt @@ -38,18 +38,35 @@ abstract class LinthPlugin : Plugin { val iconTask = project.tasks.register("generateAppIcon", IconTask::class.java) { iconTask -> - val flavors = mutableSetOf() + val flavorAndBuildType = mutableSetOf>() androidExtension.applicationVariants.forEach { variant -> val flavor = variant.flavorName.capitalize() - flavors.add(flavor) + val buildType = variant.buildType.name.capitalize() + flavorAndBuildType.add(flavor to buildType) } - iconTask.flavors = flavors + iconTask.flavorAndBuildType = flavorAndBuildType + iconTask.targetWebIcon = null } //hook iconTask into android build process project.afterEvaluate { + + val buildDir = project.layout.buildDirectory.asFile.get() + + androidExtension.productFlavors.configureEach { flavor -> + // Add the property 'launcherIconLabel' to each product flavor and set the default value to its name + //flavor.set("launcherIconLabel", flavor.name) + //flavor.ext.set("launcherIconLabelEnabled", (Boolean) null) + + // Add generated icon path to res-SourceSet. This must be here otherwise it is too late! + val sourceSet = androidExtension.sourceSets.maybeCreate(flavor.name) + sourceSet.res { + srcDir("$buildDir/generated/res/launcher-icon/${flavor.name}/") + } + } + androidExtension.applicationVariants.forEach { variant -> val variantName = variant.name.capitalize() variant.outputs.forEach { output -> @@ -61,8 +78,6 @@ abstract class LinthPlugin : Plugin { } } - - project.tasks.register("uploadToUbDiag", UploadToUbDiagTask::class.java) { uploadTask -> uploadTask.uploadKey = extension.uploadKey.get() diff --git a/plugin-build/plugin/src/main/java/ch/ubique/linth/common/IconUtils.kt b/plugin-build/plugin/src/main/java/ch/ubique/linth/common/IconUtils.kt new file mode 100644 index 0000000..1c03438 --- /dev/null +++ b/plugin-build/plugin/src/main/java/ch/ubique/linth/common/IconUtils.kt @@ -0,0 +1,167 @@ +package ch.ubique.linth.common + +import java.awt.* +import java.awt.geom.AffineTransform +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO + +object IconUtils { + + private const val DEFAULT_ICON_NAME = "ic_launcher" + + /** + * Retrieve the app icon from the application manifest + */ + private fun getIconName(manifestFile: File): String? { + if (manifestFile.isDirectory || manifestFile.exists().not()) { + return null + } + + val xmlParser = XmlParser(manifestFile) + val fileName = xmlParser.findProperty("application", "android:icon") + + return fileName?.split("/")?.get(1) + } + + /** + * Finds all icon files matching the icon specified in the given manifest. + */ + fun findIcons(resDirs: List, manifest: File): List { + val iconName = getIconName(manifest) ?: DEFAULT_ICON_NAME + + for (resDir in resDirs) { + if (resDir.exists()) { + val result = mutableListOf() + resDir.walkTopDown().maxDepth(1) + .filter { it.isDirectory && (it.name.startsWith("drawable") || it.name.startsWith("mipmap")) } + .forEach { dir -> + dir.walkTopDown().filter { it.isFile && it.name.matches(Regex(".*$iconName.(png|webp)")) } + .forEach { result.add(it) } + dir.walkTopDown().filter { it.isFile && it.name.matches(Regex(".*${iconName}_foreground.(png|webp|xml)")) } + .forEach { result.add(it) } + } + if (result.isNotEmpty()) return result + } + } + + return emptyList() + } + + /** + * Creates a layered drawable putting the label banner over the launcher icon. + */ + fun createLayeredLabel(iconFile: File, bannerLabel: String, adaptive: Boolean) { + val iconName = iconFile.name.substringBefore(".") + val iconExt = iconFile.name.substringAfter(".") + val iconNameOverlay = "${iconName}_overlay" + val iconOverlayFile = File(iconFile.parentFile, "$iconNameOverlay.png") + iconOverlayFile.delete() + + val iconNameOriginal = "${iconName}_original" + val iconOriginalFile = File(iconFile.parentFile, "$iconNameOriginal.$iconExt") + iconOriginalFile.delete() + + val resType = if (iconFile.parentFile.name.startsWith("mipmap")) "mipmap" else "drawable" + + // Create upper layer, transparent image with the same size as iconFile + val (sourceWidth, sourceHeight) = if (iconExt.equals("xml", ignoreCase = true)) { + Pair(512, 512) + } else { + val img = ImageIO.read(iconFile) + Pair(img.width, img.height) + } + val overlayBitmap = createTransparentImage(sourceWidth, sourceHeight) + + // Draw label to upper layer + drawLabelOnImage(overlayBitmap, bannerLabel, adaptive) + ImageIO.write(overlayBitmap, "png", iconOverlayFile) + + // Move iconFile to iconFile-lower-layer + iconFile.renameTo(iconOriginalFile) + + // Save layer list XML into iconFile + val layerListFile = File(iconFile.parentFile, "$iconName.xml") + val layerListXml = """ + | + | + | + | + | + | + """.trimMargin() + + layerListFile.writeText(layerListXml) + } + + fun drawLabel(sourceFile: File, targetFile: File, label: String, adaptive: Boolean) { + val img = ImageIO.read(sourceFile) + drawLabelOnImage(img, label, adaptive) + val fileExtension = sourceFile.extension + ImageIO.write(img, fileExtension, targetFile) + } + + private fun drawLabelOnImage(img: BufferedImage, label: String, adaptive: Boolean) { + val sourceWidth = img.width + val sourceHeight = img.height + val dp = img.width / 108.0 + + val g = GraphicsEnvironment.getLocalGraphicsEnvironment().createGraphics(img) + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON) + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + + val scale = if (adaptive) 1.0 else 1.5 + + val anchorRel = if (adaptive) 0.6 else 0.65 + val anchorX = (sourceWidth * anchorRel).toInt() + val anchorY = (sourceHeight * anchorRel).toInt() + + val bannerTransform = AffineTransform() + bannerTransform.rotate(Math.toRadians(-45.0), anchorX.toDouble(), anchorY.toDouble()) + g.transform = bannerTransform + + val bannerHeight = (scale * sourceHeight / 5).toInt() + val banner = Rectangle(anchorX - sourceWidth, anchorY - bannerHeight / 2, sourceWidth * 2, bannerHeight) + + // Draw banner shadow + val shadow1 = Rectangle(banner).apply { grow(0, (scale * 0.5 * dp).toInt()) } + g.color = Color(0, 0, 0, 58) + g.fill(shadow1) + + val shadow2 = Rectangle(banner).apply { size = Dimension(width, (height + scale * dp).toInt()) } + g.fill(shadow2) + + // Draw banner + g.color = Color.WHITE + g.fill(banner) + + // Set font and calculate size + val labelFontSize = (scale * sourceHeight / 7).toInt() + val labelFont = Font(Font.SANS_SERIF, Font.PLAIN, labelFontSize) + g.font = labelFont + val fontMetrics = g.fontMetrics + val labelHeight = fontMetrics.ascent - fontMetrics.descent + val labelWidth = fontMetrics.stringWidth(label.uppercase()) + + g.color = Color.decode("#273c56") + g.drawString(label.uppercase(), anchorX - labelWidth / 2, anchorY + labelHeight / 2) + } + + private fun createTransparentImage(width: Int, height: Int): BufferedImage { + val img = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + val g = GraphicsEnvironment.getLocalGraphicsEnvironment().createGraphics(img) + g.background = Color(0, true) + g.clearRect(0, 0, width, height) + g.dispose() + return img + } + + /** + * Find the largest launcher icon drawable. + */ + fun findLargestIcon(iconFiles: List): File? { + val filteredIconFiles = iconFiles.filter { !it.name.contains("_foreground") } + + return filteredIconFiles.maxByOrNull { it.length() } + } +} diff --git a/plugin-build/plugin/src/main/java/ch/ubique/linth/common/XmlParser.kt b/plugin-build/plugin/src/main/java/ch/ubique/linth/common/XmlParser.kt new file mode 100644 index 0000000..dc5a7f0 --- /dev/null +++ b/plugin-build/plugin/src/main/java/ch/ubique/linth/common/XmlParser.kt @@ -0,0 +1,48 @@ +package ch.ubique.linth.common + +import org.w3c.dom.Document +import org.w3c.dom.Element +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory + +class XmlParser( + file: File, +) { + + private val root: Element + + init { + val document = parseXml(file) + document.documentElement.normalize() + root = document.documentElement + } + + fun findProperty(tag: String, property: String): String? { + val nodeList = root.childNodes + for (i in 0 until nodeList.length) { + val node = nodeList.item(i) + + if (node.nodeType == Document.ELEMENT_NODE) { + val element = node as Element + if (element.tagName == tag) { + // Access attributes + val attributes = element.attributes + for (j in 0 until attributes.length) { + val attribute = attributes.item(j) + if (attribute.nodeName == property) { + return attribute.nodeValue + } + } + return null + } + } + } + return null + } + + private fun parseXml(file: File): Document { + val documentBuilderFactory = DocumentBuilderFactory.newInstance() + val docBuilder = documentBuilderFactory.newDocumentBuilder() + return docBuilder.parse(file) + } +} \ No newline at end of file