diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8b14b718..b1b237c4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,8 +18,15 @@ android { versionCode = 38 versionName = "2.8.2" multiDexEnabled = true + + externalNativeBuild { + cmake { + cppFlags += "-std=c++11" + } + } } + buildTypes { getByName("debug") { isMinifyEnabled = false @@ -27,19 +34,23 @@ android { isDebuggable = true // applicationIdSuffix = ".debug" // versionNameSuffix = "-debug" - buildConfigField("Long","BuildDateTime", getDateTime().toString().plus("L")) + buildConfigField("Long","BuildDateTime", "${getDateTime()}L") resValue ("string", "app_name", "Bums Launcher Debug") } getByName("release") { isMinifyEnabled = true isShrinkResources = true - buildConfigField("Long","BuildDateTime", getDateTime().toString().plus("L")) + buildConfigField("Long","BuildDateTime", "${getDateTime()}L") proguardFiles (getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") resValue ("string", "app_name", "Bums Launcher") } } - + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + } + } signingConfigs { getByName("debug") { @@ -80,6 +91,7 @@ android { kotlinOptions { jvmTarget = "1.8" } + } dependencies { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c1636ba8..c5209bc9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -79,6 +79,7 @@ android:stateNotNeeded="true" android:enableOnBackInvokedCallback="true" android:largeHeap="true" + android:debuggable="false" android:extractNativeLibs="true" android:networkSecurityConfig="@xml/network_security_config" android:hardwareAccelerated="true" @@ -86,7 +87,7 @@ android:screenOrientation="nosensor" android:windowSoftInputMode="adjustResize" android:requestLegacyExternalStorage="true" - > + tools:ignore="HardcodedDebugMode"> diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..f2cd7173 --- /dev/null +++ b/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.10.2) +project("native_renderer") + +add_library(native-renderer SHARED native_renderer.cpp) + +find_library(log-lib log) + +target_link_libraries(native-renderer ${log-lib} android) diff --git a/app/src/main/cpp/native_renderer.cpp b/app/src/main/cpp/native_renderer.cpp new file mode 100644 index 00000000..525b7aed --- /dev/null +++ b/app/src/main/cpp/native_renderer.cpp @@ -0,0 +1,82 @@ +#include +#include +#include +#include + +#define LOG_TAG "NativeRenderer" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +static ANativeWindow* window = nullptr; + +extern "C" JNIEXPORT void JNICALL +Java_bums_lunatic_launcher_wall_NativeRenderer_nativeInit(JNIEnv* env, jobject, jobject surface) { + if (window != nullptr) { + ANativeWindow_release(window); + window = nullptr; + } + window = ANativeWindow_fromSurface(env, surface); + LOGI("Native window initialized"); +} + +extern "C" JNIEXPORT void JNICALL +Java_bums_lunatic_launcher_wall_NativeRenderer_nativeRender( + JNIEnv* env, jobject, jintArray pixels, jint width, jint height, + jfloat offsetX, jfloat offsetY, jfloat fadeAlpha) { + + if (!window) return; + + ANativeWindow_Buffer buffer; + if (ANativeWindow_lock(window, &buffer, nullptr) < 0) { + LOGE("Failed to lock native window"); + return; + } + + jint* srcPixels = env->GetIntArrayElements(pixels, nullptr); + if (!srcPixels) { + ANativeWindow_unlockAndPost(window); + return; + } + + uint32_t* dstPixels = (uint32_t*)buffer.bits; + int stride = buffer.stride; + + int offsetXInt = static_cast(offsetX); + int offsetYInt = static_cast(offsetY); + + for (int y = 0; y < buffer.height; y++) { + int srcY = y - offsetYInt; + if (srcY < 0 || srcY >= height) continue; + + for (int x = 0; x < buffer.width; x++) { + int srcX = x - offsetXInt; + if (srcX < 0 || srcX >= width) continue; + + uint32_t pixel = srcPixels[srcY * width + srcX]; + + uint8_t alpha = static_cast(fadeAlpha * 255); + uint8_t r = (pixel >> 16) & 0xFF; + uint8_t g = (pixel >> 8) & 0xFF; + uint8_t b = pixel & 0xFF; + + r = (r * alpha) / 255; + g = (g * alpha) / 255; + b = (b * alpha) / 255; + + dstPixels[y * stride + x] = (0xFF << 24) | (r << 16) | (g << 8) | b; + } + } + + env->ReleaseIntArrayElements(pixels, srcPixels, JNI_ABORT); + + ANativeWindow_unlockAndPost(window); +} + +extern "C" JNIEXPORT void JNICALL +Java_bums_lunatic_launcher_wall_NativeRenderer_nativeDestroy(JNIEnv*, jobject) { + if (window) { + ANativeWindow_release(window); + window = nullptr; + LOGI("Native window released"); + } +} diff --git a/app/src/main/kotlin/bums/lunatic/launcher/helpers/HourlyLogWriter.kt b/app/src/main/kotlin/bums/lunatic/launcher/helpers/HourlyLogWriter.kt index e7d957dd..9b317f4a 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/helpers/HourlyLogWriter.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/helpers/HourlyLogWriter.kt @@ -21,6 +21,7 @@ class HourlyLogWriter(private val logDir: File) { // 로그 기록 함수 (비동기) fun writeLog(data: String) { + if (true)return Blog.LOGE("writeLog >>> ${data}") ioScope.launch { try { diff --git a/app/src/main/kotlin/bums/lunatic/launcher/receiver/NLService.kt b/app/src/main/kotlin/bums/lunatic/launcher/receiver/NLService.kt index 171e58ad..1cbafa82 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/receiver/NLService.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/receiver/NLService.kt @@ -75,7 +75,7 @@ class NLService : NotificationListenerService() { @SuppressLint("MissingPermission") @RequiresApi(Build.VERSION_CODES.S) override fun onNotificationPosted(sbn: StatusBarNotification) { - Blog.LOGE("onNotificationPosted ${sbn}") +// Blog.LOGE("onNotificationPosted ${sbn}") if (sbn.packageName.contains("bums.lunatic.launcher") == false) { val notification = sbn.notification val extras = notification.extras @@ -99,7 +99,7 @@ class NLService : NotificationListenerService() { stringBuffer.append(conversationTitle).append("\n") stringBuffer.append(summaryText).append("\n") stringBuffer.append(verificationText).append("\n") - Blog.LOGE("title >> ${title} text >> ${text} bigText >> ${bigText} extraInfo >> ${extraInfo} subText >> ${subText} conversationTitle >> ${conversationTitle} summaryText >> ${summaryText} verificationText >> ${verificationText}") +// Blog.LOGE("title >> ${title} text >> ${text} bigText >> ${bigText} extraInfo >> ${extraInfo} subText >> ${subText} conversationTitle >> ${conversationTitle} summaryText >> ${summaryText} verificationText >> ${verificationText}") mHourlyLogWriter?.writeLog("${sbn.packageName}\n${stringBuffer.toString()}") when (sbn.packageName) { "com.kakao.taxi" -> { diff --git a/app/src/main/kotlin/bums/lunatic/launcher/utils/MediaBitmapUtil.kt b/app/src/main/kotlin/bums/lunatic/launcher/utils/MediaBitmapUtil.kt new file mode 100644 index 00000000..c7162080 --- /dev/null +++ b/app/src/main/kotlin/bums/lunatic/launcher/utils/MediaBitmapUtil.kt @@ -0,0 +1,34 @@ +package bums.lunatic.launcher.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import java.io.File + +object MediaBitmapUtil { + fun decodeImageFile(file: File): Bitmap? { + return try { + val options = BitmapFactory.Options().apply { + inPreferredConfig = Bitmap.Config.RGB_565 + } + BitmapFactory.decodeFile(file.absolutePath, options) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + fun decodeVideoFrame(file: File, frameTimeUs: Long = 0L): Bitmap? { + return try { + val retriever = MediaMetadataRetriever() + retriever.setDataSource(file.absolutePath) + val bitmap = retriever.getFrameAtTime(frameTimeUs) + retriever.release() + bitmap + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} diff --git a/app/src/main/kotlin/bums/lunatic/launcher/wall/MyWallpaperService.kt b/app/src/main/kotlin/bums/lunatic/launcher/wall/MyWallpaperService.kt index 4bf6ff42..726b454a 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/wall/MyWallpaperService.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/wall/MyWallpaperService.kt @@ -1,686 +1,241 @@ package bums.lunatic.launcher.wall -import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.RectF -import android.media.MediaCodec -import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar -import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar -import android.media.MediaExtractor -import android.media.MediaFormat import android.os.Environment import android.os.Handler import android.os.HandlerThread -import android.renderscript.Allocation -import android.renderscript.Element -import android.renderscript.RenderScript -import android.renderscript.ScriptIntrinsicYuvToRGB -import android.renderscript.Type import android.service.wallpaper.WallpaperService -import android.util.DisplayMetrics import android.view.SurfaceHolder -import android.view.WindowManager -import bums.lunatic.launcher.R -import bums.lunatic.launcher.utils.Blog import java.io.File -import kotlin.random.Random class MyWallpaperService : WallpaperService() { - override fun onCreateEngine(): Engine { - return VideoEngine() - } + override fun onCreateEngine(): Engine = NativeRenderEngine() - inner class VideoEngine : WallpaperService.Engine() { + inner class NativeRenderEngine : Engine() { private lateinit var holder: SurfaceHolder - private lateinit var renderLooper: RenderLooper private lateinit var handlerThread: HandlerThread + private lateinit var handler: Handler + private var running = false - private var screenWidth: Int = 0 - private var screenHeight: Int = 0 + private var nativeRenderer: NativeRenderer? = null + + private var currentBitmap: Bitmap? = null + private var currentBitmapPixels: IntArray? = null + private var currentBitmapWidth = 0 + private var currentBitmapHeight = 0 + + private var screenWidth = 0 + private var screenHeight = 0 + + private var renderWidth = 0f + private var renderHeight = 0f + + private var maxOffset = 0f + private var moveXAxis = false + private var moveYAxis = false + + private var offsetX = 0f + private var offsetY = 0f + private var movingForward = true + + private var fadeAlpha = 1f + private val fadeDuration = 3000L // 3초 + private val imageDisplayDuration = 20_000L // 20초 + + private var currentMediaStartTime = 0L + private val frameDelayMs = 16L + + private val renderRunnable = object : Runnable { + override fun run() { + if (!running) return + + val now = System.currentTimeMillis() + val mediaElapsedMs = now - currentMediaStartTime + + // 이동 애니메이션 계산 + val travelDistance = maxOffset + val speed = if (travelDistance > 0f) (travelDistance * 2) / imageDisplayDuration.toFloat() else 0f + val deltaOffset = speed * frameDelayMs + + if (moveXAxis) { + if (movingForward) { + offsetX += deltaOffset + if (offsetX >= maxOffset) offsetX = maxOffset.also { movingForward = false } + } else { + offsetX -= deltaOffset + if (offsetX <= 0f) offsetX = 0f.also { movingForward = true } + } + offsetY = 0f + } else if (moveYAxis) { + if (movingForward) { + offsetY += deltaOffset + if (offsetY >= maxOffset) offsetY = maxOffset.also { movingForward = false } + } else { + offsetY -= deltaOffset + if (offsetY <= 0f) offsetY = 0f.also { movingForward = true } + } + offsetX = 0f + } else { + offsetX = 0f + offsetY = 0f + } + + // 페이드 인/아웃 계산 + fadeAlpha = if (mediaElapsedMs >= imageDisplayDuration - fadeDuration) { + val elapsed = mediaElapsedMs - (imageDisplayDuration - fadeDuration) + 1f - (elapsed.toFloat() / fadeDuration) + } else 1f + if (fadeAlpha < 0f) fadeAlpha = 0f + + // 비트맵에서 픽셀 추출 + currentBitmap?.let { bmp -> + if (currentBitmapPixels == null || currentBitmapWidth != bmp.width || currentBitmapHeight != bmp.height) { + currentBitmapWidth = bmp.width + currentBitmapHeight = bmp.height + currentBitmapPixels = IntArray(currentBitmapWidth * currentBitmapHeight) + } + bmp.getPixels(currentBitmapPixels!!, 0, currentBitmapWidth, 0, 0, currentBitmapWidth, currentBitmapHeight) + } + + // 네이티브 렌더 호출 + currentBitmapPixels?.let { pixels -> + nativeRenderer?.nativeRender(pixels, currentBitmapWidth, currentBitmapHeight, offsetX, offsetY, fadeAlpha) + } + + // 20초 후 미디어 교체 + if (mediaElapsedMs >= imageDisplayDuration) { + loadNextMedia() + } + + handler.postDelayed(this, frameDelayMs) + } + } override fun onCreate(surfaceHolder: SurfaceHolder) { super.onCreate(surfaceHolder) holder = surfaceHolder - val wm = getSystemService(Context.WINDOW_SERVICE) as WindowManager - val metrics = DisplayMetrics() - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { - val display = wm.currentWindowMetrics - screenWidth = display.bounds.width() - screenHeight = display.bounds.height() - } else { - @Suppress("DEPRECATION") - wm.defaultDisplay.getMetrics(metrics) - screenWidth = metrics.widthPixels - screenHeight = metrics.heightPixels - } } override fun onSurfaceCreated(holder: SurfaceHolder) { super.onSurfaceCreated(holder) - handlerThread = HandlerThread("RenderHandlerThread") - handlerThread.start() - renderLooper = RenderLooper(this@MyWallpaperService, holder, screenWidth, screenHeight, handlerThread) - renderLooper.start() + nativeRenderer = NativeRenderer() + nativeRenderer?.nativeInit(holder.surface) + + handlerThread = HandlerThread("NativeRenderThread").apply { start() } + handler = Handler(handlerThread.looper) + running = true + + val wm = getSystemService(WINDOW_SERVICE) as android.view.WindowManager + val metrics = android.util.DisplayMetrics() + wm.defaultDisplay.getMetrics(metrics) + screenWidth = metrics.widthPixels + screenHeight = metrics.heightPixels + + loadMediaFiles() + + handler.post(renderRunnable) } override fun onSurfaceDestroyed(holder: SurfaceHolder) { - super.onSurfaceDestroyed(holder) - renderLooper.stop() + running = false + handler.removeCallbacks(renderRunnable) + nativeRenderer?.nativeDestroy() handlerThread.quitSafely() + super.onSurfaceDestroyed(holder) } - override fun onSurfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { - super.onSurfaceChanged(holder, format, width, height) - screenWidth = width - screenHeight = height - renderLooper.updateScreenSize(width, height) - } - } -} + private var mediaFiles: List = emptyList() + private var currentIndex = 0 -interface Renderer { - fun render(canvas: Canvas) - fun release() - fun updateScreenSize(width: Int, height: Int) - fun resetOffset() - fun update() - fun isCycleCompleted(): Boolean -} - -class ImageRenderer( - private val context: Context, - private var bitmap: Bitmap, - private var screenWidth: Int, - private var screenHeight: Int -) : Renderer { - - private var renderWidth = 0f - private var renderHeight = 0f - private var renderStartX = 0f - private var renderStartY = 0f - - private var moveXAxis = false - private var moveYAxis = false - private var maxOffset = 0f - - private var offset = 0f - private var direction = 1 - private val speed = 2.5f - - private val holdDurationMs = 10_000L - private var noMoveStartTime: Long = 0L - private var moveCycleCompleted = false - - private val paintOverlay = Paint().apply { - color = Color.WHITE - textSize = 40f - setShadowLayer(4f, 2f, 2f, Color.DKGRAY) - } - - init { - setupScaling(bitmap.width, bitmap.height) - } - - override fun render(canvas: Canvas) { - updateOffset() - val drawX = if (moveXAxis) renderStartX - offset else renderStartX - val drawY = if (moveYAxis) renderStartY - offset else renderStartY - val dstRect = RectF(drawX, drawY, drawX + renderWidth, drawY + renderHeight) - - canvas.drawColor(Color.BLACK) - canvas.drawBitmap(bitmap, null, dstRect, null) - // canvas.drawText("Live Wallpaper Overlay", 40f, 80f, paintOverlay) - } - - override fun release() { - bitmap.recycle() - } - - override fun updateScreenSize(width: Int, height: Int) { - screenWidth = width - screenHeight = height - setupScaling(bitmap.width, bitmap.height) - resetOffset() - } - - override fun resetOffset() { - offset = 0f - direction = 1 - noMoveStartTime = 0L - moveCycleCompleted = false - } - - override fun update() { - if (!moveXAxis && !moveYAxis) { - val now = System.currentTimeMillis() - if (noMoveStartTime == 0L) noMoveStartTime = now - else if (now - noMoveStartTime >= holdDurationMs) { - noMoveStartTime = 0L - moveCycleCompleted = true - } - } else { - if (moveCycleCompleted) { - moveCycleCompleted = false - } - noMoveStartTime = 0L - } - } - - override fun isCycleCompleted(): Boolean = moveCycleCompleted - - private fun setupScaling(mediaWidth: Int, mediaHeight: Int) { - val isLandscape = mediaWidth > mediaHeight - - var scale = if (isLandscape) { - // 가로가 긴 경우 => 세로 기준 스케일링 - screenHeight.toFloat() / mediaHeight - } else { - // 세로가 긴 경우 => 가로 기준 스케일링 - screenWidth.toFloat() / mediaWidth - } - - var targetWidth = mediaWidth * scale - var targetHeight = mediaHeight * scale - - // 2차 보정: 스케일 후 크기가 기기 해상도보다 작으면 크기 맞춤 - if (targetWidth < screenWidth) { - val scaleX = screenWidth.toFloat() / mediaWidth - if (scaleX > scale) { - scale = scaleX - targetWidth = mediaWidth * scale - targetHeight = mediaHeight * scale - } - } - if (targetHeight < screenHeight) { - val scaleY = screenHeight.toFloat() / mediaHeight - if (scaleY > scale) { - scale = scaleY - targetWidth = mediaWidth * scale - targetHeight = mediaHeight * scale - } - } - - renderWidth = targetWidth - renderHeight = targetHeight - - renderStartX = 0f - renderStartY = 0f - - val diffWidth = renderWidth - screenWidth - val diffHeight = renderHeight - screenHeight - - moveXAxis = diffWidth > 1f - moveYAxis = diffHeight > 1f - - maxOffset = if (moveXAxis) diffWidth else if (moveYAxis) diffHeight else 0f - } - - private fun updateOffset() { - if (moveXAxis || moveYAxis) { - offset += direction * speed - if (offset < 0f) { - offset = 0f - direction = 1 - } - if (offset > maxOffset) { - offset = maxOffset - direction = -1 - } - } - } -} - -class VideoRenderer( - private val context: Context, - private val extractor: MediaExtractor, - private var screenWidth: Int, - private var screenHeight: Int -) : Renderer, Runnable { - private var codec: MediaCodec? = null - private var videoTrackIndex: Int = -1 - private val bufferInfo = MediaCodec.BufferInfo() - - private var videoWidth = 0 - private var videoHeight = 0 - - private var offset = 0f - private var direction = 1 - private val speed = 2.5f - - private var renderWidth = 0f - private var renderHeight = 0f - private var renderStartX = 0f - private var renderStartY = 0f - - private var moveXAxis = false - private var moveYAxis = false - private var maxOffset = 0f - - private var running = true - private var isEOS = false - - private var keyFrameRate = 60 - - private var rs: RenderScript? = null - private var yuvToRgb: ScriptIntrinsicYuvToRGB? = null - - private var bitmap: Bitmap? = null - - private val paintOverlay = Paint().apply { - color = Color.WHITE - textSize = 40f - setShadowLayer(4f, 2f, 2f, Color.DKGRAY) - } - - private var noMoveStartTime: Long = 0L - private val holdDurationMs = 10_000L - private var moveCycleCompleted = false - - private val lock = Object() - - init { - setupDecoder() - } - - override fun render(canvas: Canvas) { - synchronized(lock) { - if (bitmap != null) { - val drawX = if (moveXAxis) renderStartX - offset else renderStartX - val drawY = if (moveYAxis) renderStartY - offset else renderStartY - val dstRect = RectF(drawX, drawY, drawX + renderWidth, drawY + renderHeight) - - canvas.drawColor(Color.BLACK) - canvas.drawBitmap(bitmap!!, null, dstRect, null) - // canvas.drawText("Live Wallpaper Overlay", 40f, 80f, paintOverlay) - } - } - } - - override fun release() { - codec?.stop() - codec?.release() - extractor.release() - rs?.destroy() - bitmap?.recycle() - running = false - } - - override fun updateScreenSize(width: Int, height: Int) { - synchronized(lock) { - screenWidth = width - screenHeight = height - setupScaling(videoWidth, videoHeight) - resetOffset() - } - } - - override fun resetOffset() { - synchronized(lock) { - offset = 0f - direction = 1 - noMoveStartTime = 0L - moveCycleCompleted = false - } - } - - override fun update() { - synchronized(lock) { - if (!moveXAxis && !moveYAxis) { - val now = System.currentTimeMillis() - if (noMoveStartTime == 0L) noMoveStartTime = now - else if (now - noMoveStartTime >= holdDurationMs) { - noMoveStartTime = 0L - moveCycleCompleted = true - } + private fun loadMediaFiles() { + val mediaDir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "wallpapers") + val extensions = listOf("jpg", "jpeg", "png", "bmp", "webp") + mediaFiles = if (mediaDir.exists() && mediaDir.isDirectory) { + mediaDir.listFiles()?.filter { extensions.contains(it.extension.lowercase()) }?.toList() ?: emptyList() } else { - if (moveCycleCompleted) { - moveCycleCompleted = false + emptyList() + } + loadNextMedia() + } + + private fun loadNextMedia() { + if (mediaFiles.isEmpty()) return + + val file = mediaFiles[currentIndex] + currentIndex = (currentIndex + 1) % mediaFiles.size + loadBitmapFromFile(file) + currentMediaStartTime = System.currentTimeMillis() + offsetX = 0f + offsetY = 0f + movingForward = true + fadeAlpha = 1f + + setupScaling(currentBitmapWidth, currentBitmapHeight) + } + + private fun loadBitmapFromFile(file: File) { + val originalBitmap = BitmapFactory.decodeFile(file.absolutePath) + originalBitmap?.let { + currentBitmap?.recycle() + + setupScaling(it.width, it.height) + + val scaledWidth = renderWidth.toInt().coerceAtLeast(1) + val scaledHeight = renderHeight.toInt().coerceAtLeast(1) + + val scaledBitmap = Bitmap.createScaledBitmap(it, scaledWidth, scaledHeight, true) + + currentBitmap = scaledBitmap + currentBitmapWidth = scaledWidth + currentBitmapHeight = scaledHeight + + currentBitmapPixels = IntArray(currentBitmapWidth * currentBitmapHeight) + scaledBitmap.getPixels(currentBitmapPixels!!, 0, currentBitmapWidth, 0, 0, currentBitmapWidth, currentBitmapHeight) + + if (it != scaledBitmap) { + it.recycle() } - noMoveStartTime = 0L } } - } - override fun isCycleCompleted(): Boolean = moveCycleCompleted + private fun setupScaling(mediaWidth: Int, mediaHeight: Int) { + if (screenWidth == 0 || screenHeight == 0) return - operator fun invoke() = run() + val isLandscape = mediaWidth > mediaHeight + var scale = if (isLandscape) screenHeight.toFloat() / mediaHeight else screenWidth.toFloat() / mediaWidth - override fun run() { - try { - val inputBuffersTimeoutUs = 10000L + var targetWidth = mediaWidth * scale + var targetHeight = mediaHeight * scale - while (running && !Thread.currentThread().isInterrupted) { - if (isEOS) { - - continue + if (targetWidth < screenWidth) { + val scaleX = screenWidth.toFloat() / mediaWidth + if (scaleX > scale) { + scale = scaleX + targetWidth = mediaWidth * scale + targetHeight = mediaHeight * scale } + } - val inputBufferIndex = codec?.dequeueInputBuffer(inputBuffersTimeoutUs) ?: -1 - if (inputBufferIndex >= 0) { - val inputBuffer = codec?.getInputBuffer(inputBufferIndex) - if (inputBuffer != null) { - val sampleSize = extractor.readSampleData(inputBuffer, 0) - if (sampleSize < 0) { - codec?.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) - isEOS = true - } else { - val pts = extractor.sampleTime - codec?.queueInputBuffer(inputBufferIndex, 0, sampleSize, pts, 0) - extractor.advance() - } - } + if (targetHeight < screenHeight) { + val scaleY = screenHeight.toFloat() / mediaHeight + if (scaleY > scale) { + scale = scaleY + targetWidth = mediaWidth * scale + targetHeight = mediaHeight * scale } - - val outputBufferIndex = codec?.dequeueOutputBuffer(bufferInfo, inputBuffersTimeoutUs) ?: -1 - if (outputBufferIndex >= 0) { - val outputBuffer = codec?.getOutputBuffer(outputBufferIndex) - if (bufferInfo.size > 0 && outputBuffer != null) { - val format = codec?.outputFormat - - val colorFormat = format?.getInteger(MediaFormat.KEY_COLOR_FORMAT) ?: 0 - - val rawYUV = ByteArray(bufferInfo.size) - outputBuffer.get(rawYUV) - outputBuffer.position(0) - - val nv21Data = when (colorFormat) { - COLOR_FormatYUV420SemiPlanar -> nv12ToNv21(rawYUV, videoWidth, videoHeight) - COLOR_FormatYUV420Planar -> i420ToNv21(rawYUV, videoWidth, videoHeight) - else -> rawYUV - } - - val bmp = convertYUVToBitmap(nv21Data, videoWidth, videoHeight) - synchronized(lock) { - bitmap?.recycle() - bitmap = bmp - } - - if (moveXAxis || moveYAxis) { - offset += direction * speed - if (offset < 0f) { - offset = 0f - direction = 1 - } - if (moveXAxis && offset > maxOffset) { - offset = maxOffset - direction = -1 - } else if (moveYAxis && offset > maxOffset) { - offset = maxOffset - direction = -1 - } - - noMoveStartTime = 0L - } else { - val now = System.currentTimeMillis() - if (noMoveStartTime == 0L) noMoveStartTime = now - else if (now - noMoveStartTime >= holdDurationMs) { - noMoveStartTime = 0L - moveCycleCompleted = true - } - } - } - codec?.releaseOutputBuffer(outputBufferIndex, false) - } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { - // 필요시 처리 - } - - } - } catch (e: Exception) { - e.printStackTrace() - } - } - private fun setupDecoder() { - for (i in 0 until extractor.trackCount) { - val format = extractor.getTrackFormat(i) - val mime = format.getString(MediaFormat.KEY_MIME) ?: "" - if (format.containsKey(MediaFormat.KEY_FRAME_RATE)) { - keyFrameRate = format.getInteger(MediaFormat.KEY_FRAME_RATE) - } else { - keyFrameRate = 60 - } - if (mime.startsWith("video/")) { - videoTrackIndex = i - extractor.selectTrack(i) - videoWidth = format.getInteger(MediaFormat.KEY_WIDTH) - videoHeight = format.getInteger(MediaFormat.KEY_HEIGHT) - break - } - } - if (videoTrackIndex < 0) return + renderWidth = targetWidth + renderHeight = targetHeight - codec?.stop() - codec?.release() - codec = MediaCodec.createDecoderByType(extractor.getTrackFormat(videoTrackIndex).getString(MediaFormat.KEY_MIME)!!).apply { - configure(extractor.getTrackFormat(videoTrackIndex), null, null, 0) - start() - } + val diffWidth = renderWidth - screenWidth + val diffHeight = renderHeight - screenHeight - rs?.destroy() - rs = RenderScript.create(context) - yuvToRgb?.destroy() - yuvToRgb = ScriptIntrinsicYuvToRGB.create(rs, Element.U8_4(rs)) + moveXAxis = diffWidth > 1f + moveYAxis = diffHeight > 1f - setupScaling(videoWidth, videoHeight) - resetOffset() - } - - private fun setupScaling(mediaWidth: Int, mediaHeight: Int) { - val isLandscape = mediaWidth > mediaHeight - - var scale = if (isLandscape) { - screenHeight.toFloat() / mediaHeight - } else { - screenWidth.toFloat() / mediaWidth - } - - var targetWidth = mediaWidth * scale - var targetHeight = mediaHeight * scale - - if (targetWidth < screenWidth) { - val scaleX = screenWidth.toFloat() / mediaWidth - if (scaleX > scale) { - scale = scaleX - targetWidth = mediaWidth * scale - targetHeight = mediaHeight * scale - } - } - if (targetHeight < screenHeight) { - val scaleY = screenHeight.toFloat() / mediaHeight - if (scaleY > scale) { - scale = scaleY - targetWidth = mediaWidth * scale - targetHeight = mediaHeight * scale - } - } - - renderWidth = targetWidth - renderHeight = targetHeight - - renderStartX = 0f - renderStartY = 0f - - val diffWidth = renderWidth - screenWidth - val diffHeight = renderHeight - screenHeight - - moveXAxis = diffWidth > 1f - moveYAxis = diffHeight > 1f - - maxOffset = if (moveXAxis) diffWidth else if (moveYAxis) diffHeight else 0f - } - - private fun convertYUVToBitmap(yuvByteArray: ByteArray, width: Int, height: Int): Bitmap { - val minSize = width * height * 3 / 2 - val realData = if (yuvByteArray.size >= minSize) yuvByteArray.copyOf(minSize) else yuvByteArray - - val yuvType = Type.Builder(rs, Element.U8(rs)).setX(minSize) - val inAllocation = Allocation.createTyped(rs, yuvType.create(), Allocation.USAGE_SCRIPT) - - val rgbaType = Type.Builder(rs, Element.RGBA_8888(rs)).setX(width).setY(height) - val outAllocation = Allocation.createTyped(rs, rgbaType.create(), Allocation.USAGE_SCRIPT) - - inAllocation.copyFrom(realData) - yuvToRgb?.setInput(inAllocation) - yuvToRgb?.forEach(outAllocation) - - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - outAllocation.copyTo(bitmap) - - inAllocation.destroy() - outAllocation.destroy() - - return bitmap - } - - fun nv12ToNv21(nv12: ByteArray, width: Int, height: Int): ByteArray { - val frameSize = width * height - val nv21 = ByteArray(frameSize * 3 / 2) - - System.arraycopy(nv12, 0, nv21, 0, frameSize) - - var i = 0 - while (i < frameSize / 2 - 1) { - nv21[frameSize + i] = nv12[frameSize + i + 1] - nv21[frameSize + i + 1] = nv12[frameSize + i] - i += 2 - } - return nv21 - } - - fun i420ToNv21(i420: ByteArray, width: Int, height: Int): ByteArray { - val frameSize = width * height - val qFrameSize = frameSize / 4 - val nv21 = ByteArray(frameSize * 3 / 2) - - System.arraycopy(i420, 0, nv21, 0, frameSize) - - val u = frameSize - val v = frameSize + qFrameSize - - for (i in 0 until qFrameSize) { - nv21[frameSize + i * 2] = i420[v + i] - nv21[frameSize + i * 2 + 1] = i420[u + i] - } - return nv21 - } -} - -class RenderLooper( - private val context: Context, - private val holder: SurfaceHolder, - private var screenWidth: Int, - private var screenHeight: Int, - handlerThread: HandlerThread -) { - private val handler = Handler(handlerThread.looper) - @Volatile - private var running = false - - private var renderer: Renderer? = null - private var mediaFiles: Array? = null - - private val frameDelayMs = 1000L / 33L // 약 60fps - - private val renderRunnable = object : Runnable { - override fun run() { - if (!running) return - val canvas = holder.lockCanvas() - val currentRenderer = renderer - if (canvas != null && currentRenderer != null) { - synchronized(currentRenderer) { - currentRenderer.render(canvas) - } - holder.unlockCanvasAndPost(canvas) - } - renderer?.update() - if (renderer?.isCycleCompleted() == true) { - loadNextMedia() - handler.postDelayed(this, frameDelayMs) - } - handler.postDelayed(this, frameDelayMs) - } - } - - fun start() { - running = true - initMediaFiles() - loadNextMedia() - handler.post(renderRunnable) - } - - fun stop() { - running = false - handler.removeCallbacks(renderRunnable) - } - - private fun initMediaFiles() { - val youtubeDLDir = File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), - "wallPapers" - ) - if (youtubeDLDir.exists() && youtubeDLDir.isDirectory && youtubeDLDir.listFiles().size > 2) { - val supportedImageExtensions = listOf("jpg", "jpeg", "png", "bmp", "webp") - val supportedVideoExtensions = listOf("mp4", "mkv", "avi") - mediaFiles = youtubeDLDir.listFiles { file -> - val ext = file.extension.lowercase() - ext in supportedImageExtensions || ext in supportedVideoExtensions - } - } - Blog.LOGE("mediaFiles >>> ${mediaFiles?.size}") - } - - fun loadNextMedia() { - renderer?.release() - if (mediaFiles == null || mediaFiles!!.isEmpty()) { - prepareRawVideo() - return - } - - mediaFiles?.random()?.let { - loadMedia(it) - } - - } - - private fun loadMedia(file: File) { - val ext = file.extension.lowercase() - renderer?.resetOffset() - - if (ext in listOf("jpg", "jpeg", "png", "bmp", "webp")) { - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - renderer = ImageRenderer(context, bitmap, screenWidth, screenHeight) - } else { - try { - val extractor = MediaExtractor() - extractor.setDataSource(file.absolutePath) - renderer = VideoRenderer(context, extractor, screenWidth, screenHeight) - } catch (e: Exception) { - e.printStackTrace() - prepareRawVideo() - } - } - } - - fun updateScreenSize(width: Int, height: Int) { - screenWidth = width - screenHeight = height - renderer?.updateScreenSize(width, height) - } - - private fun prepareRawVideo() { - try { - val extractor = MediaExtractor() - val afd = context.resources.openRawResourceFd(R.raw.sample) - extractor.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) - afd.close() - renderer = VideoRenderer(context, extractor, screenWidth, screenHeight) - } catch (e: Exception) { - e.printStackTrace() + maxOffset = if (moveXAxis) diffWidth else if (moveYAxis) diffHeight else 0f } } } diff --git a/app/src/main/kotlin/bums/lunatic/launcher/wall/NativeRenderer.kt b/app/src/main/kotlin/bums/lunatic/launcher/wall/NativeRenderer.kt new file mode 100644 index 00000000..bd3cf2ef --- /dev/null +++ b/app/src/main/kotlin/bums/lunatic/launcher/wall/NativeRenderer.kt @@ -0,0 +1,22 @@ +package bums.lunatic.launcher.wall + +import android.view.Surface + +class NativeRenderer { + companion object { + init { + System.loadLibrary("native-renderer") + } + } + + external fun nativeInit(surface: Surface) + external fun nativeRender( + pixels: IntArray, + width: Int, + height: Int, + offsetX: Float, + offsetY: Float, + fadeAlpha: Float + ) + external fun nativeDestroy() +}