diff --git a/app/src/main/java/com/futo/platformplayer/views/IdenticonView.kt b/app/src/main/java/com/futo/platformplayer/views/IdenticonView.kt index fd380b73..63a50656 100644 --- a/app/src/main/java/com/futo/platformplayer/views/IdenticonView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/IdenticonView.kt @@ -1,32 +1,30 @@ package com.futo.platformplayer.views import android.content.Context +import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Path +import android.graphics.PointF import android.util.AttributeSet import android.view.View import java.security.MessageDigest +import kotlin.math.max +import kotlin.math.min class IdenticonView(context: Context, attrs: AttributeSet) : View(context, attrs) { var hashString: String = "default" set(value) { field = value hash = md5(value) + iconGenerator = null invalidate() } private var hash = ByteArray(16) - + private var iconGenerator: IconGenerator? = null private val path = Path() - private val paint = Paint().apply { - style = Paint.Style.FILL - } - - init { - hashString = "default" - } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) @@ -39,16 +37,11 @@ class IdenticonView(context: Context, attrs: AttributeSet) : View(context, attrs canvas.clipPath(clipPath) - val size = width.coerceAtMost(height) / 5 - val colors = generateColorsFromHash(hash) - - for (x in 0 until 5) { - for (y in 0 until 5) { - val shapeIndex = getShapeIndex(x, y, hash) - paint.color = colors[shapeIndex % colors.size] - drawShape(canvas, x, y, size, shapeIndex) - } + if (iconGenerator == null) { + iconGenerator = IconGenerator(min(height, width).toFloat(), hash) } + + iconGenerator?.render(canvas) } private fun md5(input: String): ByteArray { @@ -56,62 +49,408 @@ class IdenticonView(context: Context, attrs: AttributeSet) : View(context, attrs return md.digest(input.toByteArray(Charsets.UTF_8)) } - private fun generateColorsFromHash(hash: ByteArray): List { - val hue = hash[0].toFloat() / 255f - return listOf( - adjustColor(hue, 0.5f, 0.4f), - adjustColor(hue, 0.5f, 0.8f), - adjustColor(hue, 0.5f, 0.3f, 0.9f), - adjustColor(hue, 0.5f, 0.4f, 0.7f) - ) + interface Shape { + fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) } - private fun getShapeIndex(x: Int, y: Int, hash: ByteArray): Int { - val index = if (x < 3) y else 4 - y - return hash[index].toInt() shr x * 2 and 0x03 + class CutCorner : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + val k = size * 0.42f + val path = Path().apply { + moveTo(0f, 0f) + lineTo(size, 0f) + lineTo(size, size - k * 2) + lineTo(size - k, size) + lineTo(0f, size) + close() + } + canvas.drawPath(path, paint) + } } - private fun drawShape(canvas: Canvas, x: Int, y: Int, size: Int, shapeIndex: Int) { - val left = x * size.toFloat() - val top = y * size.toFloat() - val path = Path() + class SideTriangle : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + val w = size / 2 + val h = size * 0.8f + val path = Path().apply { + moveTo(size - w, 0f) + lineTo(size, h) + lineTo(size - w, h) + close() + } + canvas.drawPath(path, paint) + } + } - when (shapeIndex) { - 0 -> { - // Square - path.addRect(left, top, left + size, top + size, Path.Direction.CW) + class MiddleSquare : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + val s = size / 3 + canvas.drawRect(s, s, size - s, size - s, paint) + } + } + + class CornerSquare : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + val inner = size * 0.1f + val outer = max(1f, size * 0.25f) + canvas.drawRect(outer, outer, size - inner - outer, size - inner - outer, paint) + } + } + + class OffCenterCircle : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + val m = size * 0.15f + val s = size * 0.5f + canvas.drawCircle(size - s - m, size - s - m, s / 2, paint) + } + } + + class NegativeTriangle : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + val inner = size * 0.1f + val outer = inner * 4 + val path = Path().apply { + addRect(0f, 0f, size, size, Path.Direction.CW) + moveTo(outer, outer) + lineTo(size - inner, outer) + lineTo(outer + (size - outer - inner) / 2, size - inner) + close() } - 1 -> { - // Circle - val radius = size / 2f - path.addCircle(left + radius, top + radius, radius, Path.Direction.CW) + canvas.drawPath(path, paint) + } + } + + class CutSquare : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + val path = Path().apply { + moveTo(0f, 0f) + lineTo(size, 0f) + lineTo(size, size * 0.7f) + lineTo(size * 0.4f, size * 0.4f) + lineTo(size * 0.7f, size) + lineTo(0f, size) + close() } - 2 -> { - // Diamond - val halfSize = size / 2f - path.moveTo(left + halfSize, top) - path.lineTo(left + size, top + halfSize) - path.lineTo(left + halfSize, top + size) - path.lineTo(left, top + halfSize) - path.close() + canvas.drawPath(path, paint) + } + } + + class CornerPlusTriangle : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + val halfSize = size / 2 + canvas.drawRect(0f, 0f, size, halfSize, paint) + canvas.drawRect(0f, halfSize, halfSize, size, paint) + val path = Path().apply { + moveTo(halfSize, halfSize) + lineTo(size, halfSize) + lineTo(halfSize, size) + close() } - 3 -> { - // Triangle - path.moveTo(left + size / 2f, top) - path.lineTo(left + size, top + size) - path.lineTo(left, top + size) - path.close() + canvas.drawPath(path, paint) + } + } + + class NegativeSquare : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + val inner = size * 0.14f + val outer = size * 0.35f + val path = Path().apply { + addRect(0f, 0f, size, size, Path.Direction.CW) + addRect(outer, outer, size - outer - inner, size - outer - inner, Path.Direction.CCW) + } + canvas.drawPath(path, paint) + } + } + + class NegativeCircle : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + val inner = size * 0.12f + val outer = inner * 3 + val path = Path().apply { + addRect(0f, 0f, size, size, Path.Direction.CW) + addCircle(outer, outer, (size - inner - outer) / 2, Path.Direction.CCW) + } + canvas.drawPath(path, paint) + } + } + + class NegativeRhombus : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + val m = size * 0.25f + val path = Path().apply { + addRect(0f, 0f, size, size, Path.Direction.CW) + moveTo(m, size / 2) + lineTo(size / 2, m) + lineTo(size - m, size / 2) + lineTo(size / 2, size - m) + close() + } + canvas.drawPath(path, paint) + } + } + + class ConditionalCircle : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + if (index == 0) { + val m = size * 0.4f + val s = size * 1.2f + canvas.drawCircle(m, m, s / 2, paint) + } + } + } + + class HalfTriangle : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + val path = Path().apply { + moveTo(size / 2, size / 2) + lineTo(size, size / 2) + lineTo(size / 2, size) + close() + } + canvas.drawPath(path, paint) + } + } + + class Triangle(val corner: Int = 0) : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + val path = Path().apply { + when (corner) { + 0 -> { + moveTo(0f, 0f) + lineTo(size, 0f) + lineTo(0f, size) + } + 1 -> { + moveTo(size, 0f) + lineTo(size, size) + lineTo(0f, size) + } + 2 -> { + moveTo(0f, 0f) + lineTo(size, 0f) + lineTo(size, size) + } + 3 -> { + moveTo(0f, 0f) + lineTo(0f, size) + lineTo(size, size) + } + } + close() + } + canvas.drawPath(path, paint) + } + } + + class BottomHalfTriangle : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + val path = Path().apply { + moveTo(0f, size / 2) + lineTo(size, size / 2) + lineTo(size / 2, size) + close() + } + canvas.drawPath(path, paint) + } + } + + class Rhombus : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + val path = Path().apply { + moveTo(size / 2, 0f) + lineTo(size, size / 2) + lineTo(size / 2, size) + lineTo(0f, size / 2) + close() + } + canvas.drawPath(path, paint) + } + } + + class Circle : Shape { + override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) { + val m = size / 6 + canvas.drawCircle(m, m, size / 2 - m, paint) + } + } + + class IconGenerator(private val size: Float, private val hash: ByteArray) { + private val digits: ByteArray + private var selectedColors = arrayOf() + + init { + digits = ByteArray(max(12, hash.size * 2)) + var index = 0 + for (byte in hash) { + if (index >= digits.size) { + break + } + digits[index] = ((byte.toInt() shr 4) and 0x0f).toByte() + digits[index + 1] = (byte.toInt() and 0x0f).toByte() + index += 2 + } + selectColors() + } + + private fun selectColors() { + val value = hash.copyOfRange(hash.size - 4, hash.size).fold(0) { acc, byte -> + (acc shl 8) or (byte.toInt() and 0xFF) + } and 0x0FFFFFFF + val colorTheme = ColorTheme(hue = value.toFloat() / 0x0FFFFFFF) + + val selectedColorIndices = mutableListOf() + for (i in 0 until 3) { + val index = (digits[8 + i].toInt() % colorTheme.colors.size) + selectedColorIndices.add(colorTheme.validateIndex(index, selectedColorIndices)) + } + + selectedColors = selectedColorIndices.map { index -> + Paint().apply { + color = colorTheme.colors[index] + style = Paint.Style.FILL + } + }.toTypedArray() + } + + fun renderBitmap(): Bitmap { + val bitmap = Bitmap.createBitmap(size.toInt(), size.toInt(), Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + render(canvas) + return bitmap + } + + fun render(canvas: Canvas) { + canvas.drawColor(Color.WHITE) + + renderShape(canvas, 0, outerShapes, 2, 3, arrayOf( + PointF(1f, 0f), + PointF(2f, 0f), + PointF(2f, 3f), + PointF(1f, 3f), + PointF(0f, 1f), + PointF(3f, 1f), + PointF(3f, 2f), + PointF(0f, 2f), + )) + renderShape(canvas, 1, outerShapes, 4, 5, arrayOf( + PointF(0f, 0f), + PointF(3f, 0f), + PointF(3f, 3f), + PointF(0f, 3f), + )) + renderShape(canvas, 2, centerShapes, 1, null, arrayOf( + PointF(1f, 1f), + PointF(2f, 1f), + PointF(2f, 2f), + PointF(1f, 2f), + )) + } + + private fun renderShape( + canvas: Canvas, + colorIndex: Int, + shapes: Array, + index: Int, + rotationIndex: Int?, + positions: Array + ) { + val cellSize = size / 4 + var r = rotationIndex?.let { digits[it].toInt() } ?: 0 + val shape = shapes[digits[index].toInt() % shapes.size] + + val paint = Paint().apply { + color = selectedColors[colorIndex % selectedColors.size].color + style = Paint.Style.FILL + } + + for ((idx, position) in positions.withIndex()) { + canvas.save() + canvas.translate(position.x * cellSize, position.y * cellSize) + canvas.translate(cellSize / 2, cellSize / 2) + canvas.rotate((r % 4) * 90f) + canvas.translate(-cellSize / 2, -cellSize / 2) + + shape.draw(canvas, cellSize, idx, paint) + canvas.restore() + r++ } } - canvas.drawPath(path, paint) + class ColorTheme(val hue: Float, val saturation: Float = 0.5f) { + val colors: List + + init { + colors = listOf( + // Dark gray + grayscaleColor(0f), + // Mid color + hslColor(hue, saturation, colorLightness(0.5f)), + // Light gray + grayscaleColor(1f), + // Light color + hslColor(hue, saturation, colorLightness(1f)), + // Dark color + hslColor(hue, saturation, colorLightness(0f)) + ) + } + + fun validateIndex(index: Int, selected: List): Int { + return if (isDuplicate(index, listOf(0, 4), selected) || isDuplicate(index, listOf(2, 3), selected)) { + 1 + } else { + index + } + } + + private fun isDuplicate(index: Int, values: List, selected: List): Boolean { + if (!values.contains(index)) return false + return values.any { selected.contains(it) } + } + + private fun colorLightness(value: Float): Float = lightness(value, 0.4f, 0.8f) + + private fun grayscaleLightness(value: Float): Float = lightness(value, 0.3f, 0.9f) + + private fun lightness(value: Float, min: Float, max: Float): Float { + val lightness = min + value * (max - min) + return minOf(1f, maxOf(0f, lightness)) + } + + private fun grayscaleColor(lightness: Float): Int { + return Color.HSVToColor(floatArrayOf(0f, 0f, lightness)) + } + + private fun hslColor(hue: Float, saturation: Float, lightness: Float): Int { + return Color.HSVToColor(floatArrayOf(hue, saturation, lightness)) + } + } } - private fun adjustColor(hue: Float, saturation: Float, lightness: Float, alpha: Float = 1.0f): Int { - val color = Color.HSVToColor(floatArrayOf(hue * 360, saturation, lightness)) - val red = Color.red(color) - val green = Color.green(color) - val blue = Color.blue(color) - return Color.argb((alpha * 255).toInt(), red, green, blue) + companion object { + val centerShapes = arrayOf( + CutCorner(), + SideTriangle(), + MiddleSquare(), + CornerSquare(), + OffCenterCircle(), + NegativeTriangle(), + CutSquare(), + HalfTriangle(), + CornerPlusTriangle(), + CutSquare(), + NegativeCircle(), + HalfTriangle(), + NegativeRhombus(), + ConditionalCircle() + ) + + val outerShapes = arrayOf( + Triangle(), + BottomHalfTriangle(), + Rhombus(), + Circle(), + ) + + private const val TAG = "IdenticonView" } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt b/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt index ed6a4fbf..2f592123 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt @@ -34,6 +34,7 @@ class CreatorThumbnail : ConstraintLayout { _imageChannelThumbnail = findViewById(R.id.image_channel_thumbnail); _identicon = findViewById(R.id.identicon); _imageChannelThumbnail.clipToOutline = true; + _identicon.clipToOutline = true; _imageChannelThumbnail.visibility = View.GONE _imageNewActivity = findViewById(R.id.image_new_activity); _imageNeoPass = findViewById(R.id.image_neopass); diff --git a/app/src/main/res/layout/view_creator_thumbnail.xml b/app/src/main/res/layout/view_creator_thumbnail.xml index 4dad3e39..319b62ec 100644 --- a/app/src/main/res/layout/view_creator_thumbnail.xml +++ b/app/src/main/res/layout/view_creator_thumbnail.xml @@ -12,8 +12,8 @@ android:layout_width="match_parent" android:layout_height="0dp" app:layout_constraintDimensionRatio="1:1" - app:srcCompat="@drawable/ic_futo_logo" android:background="@drawable/rounded_outline" + android:clipToOutline="true" android:contentDescription="@string/channel_image" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" @@ -24,7 +24,6 @@ android:layout_width="match_parent" android:layout_height="0dp" app:layout_constraintDimensionRatio="1:1" - app:srcCompat="@drawable/ic_futo_logo" android:background="@drawable/rounded_outline" android:clipToOutline="true" android:scaleType="centerCrop"