sensor landscape fix

This commit is contained in:
BeZide93 2025-09-24 17:39:57 -05:00 committed by KeatonTheBot
parent 09bc10b15d
commit c42663115b
6 changed files with 356 additions and 42 deletions

View file

@ -34,7 +34,8 @@
android:name=".MainActivity"
android:launchMode="singleTop"
android:exported="true"
android:screenOrientation="sensorLandscape"
android:screenOrientation="unspecified"
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:hardwareAccelerated="true"
android:theme="@style/Theme.KenjinxAndroid">

View file

@ -37,6 +37,9 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su
// Stabilizer-State
private var stabilizerActive = false
// last known Android rotation (0,1,2,3)
private var lastRotation: Int? = null
var currentSurface: Long = -1
private set
@ -66,8 +69,10 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su
val sizeChanged = (_width != width || _height != height)
if (sizeChanged) {
// Requery Surface / Window handle and report to C#
currentSurface = _nativeWindow.requeryWindowHandle()
_nativeWindow.swapInterval = 0
try { KenjinxNative.deviceSetWindowHandle(currentWindowHandle) } catch (_: Throwable) {}
}
_width = width
@ -77,7 +82,7 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su
start(holder)
// Do not set size immediately → Stabilizer takes over
startStabilizedResize(expectedRotation = null)
startStabilizedResize(expectedRotation = lastRotation)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
@ -107,11 +112,27 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su
val id = mainViewModel.physicalControllerManager?.connect()
mainViewModel.motionSensorManager?.setControllerId(id ?: -1)
// NO graphicsRendererSetSize here we set it via the stabilizer!
// ❌ Removed: initial "flip" at 270° (caused 90° lock at start right)
// NativeHelpers.instance.setIsInitialOrientationFlipped(mainViewModel.activity.display?.rotation == 3)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
NativeHelpers.instance.setIsInitialOrientationFlipped(mainViewModel.activity.display?.rotation == 3)
// ✅ Correct: Report current Android rotation directly to the native site
val currentRot = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
mainViewModel.activity.display?.rotation
} else {
TODO("VERSION.SDK_INT < R")
}
lastRotation = currentRot
try {
KenjinxNative.setSurfaceRotationByAndroidRotation(currentRot)
// Pass the window handle for safety reasons (if Surface has just been refreshed)
try { KenjinxNative.deviceSetWindowHandle(currentWindowHandle) } catch (_: Throwable) {}
// Swapchain/Viewport “knock”: set identical size again
if (width > 0 && height > 0) {
try { KenjinxNative.resizeRendererAndInput(width, height) } catch (_: Throwable) {}
}
} catch (_: Throwable) {}
// NO graphicsRendererSetSize here we set it via the stabilizer!
_guestThread = thread(start = true, name = "KenjinxGuest") {
runGame()
@ -182,17 +203,42 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su
}
/**
* Wird von der Activity bei Rotations-/Layoutwechsel aufgerufen.
* Reicht die aktuelle Rotation durch, damit wir ggf. Breite/Höhe tauschen können.
* Called on the activity when the rotation/layout changes.
* Detects 90°270° and immediately forces a NativeWindow query.
*/
fun onOrientationOrSizeChanged(rotation: Int? = null) {
if (_isClosed) return
val old = lastRotation
lastRotation = rotation
val isSideFlip = (old == 1 && rotation == 3) || (old == 3 && rotation == 1)
if (isSideFlip) {
// 1) Report NativeRotation
try { KenjinxNative.setSurfaceRotationByAndroidRotation(rotation) } catch (_: Throwable) {}
// 2) Requery NativeWindow immediately (forces real rebind) + window handle to C#
try {
currentSurface = _nativeWindow.requeryWindowHandle()
_nativeWindow.swapInterval = 0
try { KenjinxNative.deviceSetWindowHandle(currentWindowHandle) } catch (_: Throwable) {}
} catch (_: Throwable) {}
// 3) Swapchain/Viewport directly “knock”, set identical size again
val w = if (holder.surfaceFrame.width() > 0) holder.surfaceFrame.width() else width
val h = if (holder.surfaceFrame.height() > 0) holder.surfaceFrame.height() else height
if (w > 0 && h > 0) {
try { KenjinxNative.resizeRendererAndInput(w, h) } catch (_: Throwable) {}
}
}
startStabilizedResize(rotation)
}
/**
* Wartet kurz, bis das Surface seine finalen Maße nach der Drehung hat,
* prüft Plausibilität (Portrait/Landscape) und setzt erst dann die Größe.
* Wait a moment until the surface has its final dimensions after rotation,
* checks plausibility (portrait/landscape) and only then sets the size.
*/
private fun startStabilizedResize(expectedRotation: Int?) {
if (_isClosed) return

View file

@ -5,6 +5,7 @@ import com.sun.jna.Library
import com.sun.jna.Native
import org.kenjinx.android.viewmodels.GameInfo
import java.util.Collections
import android.view.Surface
interface KenjinxNativeJna : Library {
fun deviceInitialize(
@ -87,6 +88,12 @@ interface KenjinxNativeJna : Library {
fun userGetAllUsers(): Array<String>
fun deviceGetDlcContentList(path: String, titleId: Long): Array<String>
fun loggingEnabledGraphicsLog(enabled: Boolean)
// Surface rotation (0/90/180/270 degrees)
fun deviceSetSurfaceRotation(degrees: Int)
// (optional alias): compact resize shortcut
fun deviceResize(width: Int, height: Int)
// Set window handle after each query
fun deviceSetWindowHandle(handle: Long)
}
val jnaInstance: KenjinxNativeJna = Native.load(
@ -112,14 +119,34 @@ object KenjinxNative : KenjinxNativeJna by jnaInstance {
@JvmStatic
fun updateProgress(infoPtr: Long, progress: Float) {
// Get string from native pointer and push into progress overlay
val text = NativeHelpers.instance.getStringJava(infoPtr)
MainActivity.mainViewModel?.gameHost?.setProgress(text, progress)
}
@JvmStatic
fun onSurfaceSizeChanged(width: Int, height: Int) {
// No-op: Placeholder. If you have a hook in C#/C++ (swapchain/viewport new),
// you can call it here.
// No-Op: Placeholder Hook if needed.
}
@JvmStatic
fun setSurfaceRotationByAndroidRotation(androidRotation: Int?) {
val degrees = when (androidRotation) {
Surface.ROTATION_0 -> 0
Surface.ROTATION_90 -> 90
Surface.ROTATION_180 -> 180
Surface.ROTATION_270 -> 270
else -> 0
}
try { deviceSetSurfaceRotation(degrees) } catch (_: Throwable) {}
}
@JvmStatic
fun resizeRendererAndInput(width: Int, height: Int) {
try {
// Alternatively: deviceResize(width, height)
graphicsRendererSetSize(width, height)
inputSetClientSize(width, height)
} catch (_: Throwable) {}
}
/**
@ -159,8 +186,7 @@ object KenjinxNative : KenjinxNativeJna by jnaInstance {
}
/**
* Variant B (strings directly). Used by newer JNI/Interop paths.
* Signature exactly matches the C# call in AndroidUIHandler.cs / Interop.UpdateUiHandler(...).
* Variant B (strings directly). Used by newer JNI/interop paths.
*/
@JvmStatic
fun uiHandlerUpdate(

View file

@ -10,6 +10,8 @@ import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.net.Uri
import android.util.Log
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
@ -20,6 +22,7 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import com.anggrayudi.storage.SimpleStorageHelper
import com.sun.jna.JNIEnv
import org.kenjinx.android.ui.theme.KenjinxAndroidTheme
@ -27,6 +30,12 @@ import org.kenjinx.android.viewmodels.MainViewModel
import org.kenjinx.android.viewmodels.QuickSettings
import org.kenjinx.android.viewmodels.GameModel
import org.kenjinx.android.views.MainView
import java.io.File
import android.content.res.Configuration
import android.content.Context
import android.content.pm.ActivityInfo
import android.hardware.display.DisplayManager
import android.view.Surface
class MainActivity : BaseActivity() {
private var physicalControllerManager: PhysicalControllerManager =
@ -34,17 +43,105 @@ class MainActivity : BaseActivity() {
private lateinit var motionSensorManager: MotionSensorManager
private var _isInit: Boolean = false
private val handler = Handler(Looper.getMainLooper())
private val delayedHandleIntent = object : Runnable {
override fun run() {
handleIntent()
}
}
private val delayedHandleIntent = object : Runnable { override fun run() { handleIntent() } }
var storedIntent: Intent = Intent()
var isGameRunning = false
var isActive = false
var storageHelper: SimpleStorageHelper? = null
lateinit var uiHandler: UiHandler
// Display Rotation + Orientation Handling
private lateinit var displayManager: DisplayManager
private var lastKnownRotation: Int? = null
private var pulsingOrientation = false
private val TAG_ROT = "RotationDebug"
private val displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) {}
override fun onDisplayRemoved(displayId: Int) {}
override fun onDisplayChanged(displayId: Int) {
if (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
display?.displayId != displayId
} else {
TODO("VERSION.SDK_INT < R")
}
) return
val rot = display?.rotation
if (rot == lastKnownRotation) return
Log.d(TAG_ROT, "onDisplayChanged: display.rotation=$rot${deg(rot)}°")
val pref = QuickSettings(this@MainActivity).orientationPreference
val old = lastKnownRotation
lastKnownRotation = rot
// 1) Inform Native/Renderer
try { KenjinxNative.setSurfaceRotationByAndroidRotation(rot) } catch (_: Throwable) {}
// 2) Initiate host resize
if (isGameRunning) {
handler.post {
try { mainViewModel?.gameHost?.onOrientationOrSizeChanged(rot) } catch (_: Throwable) {}
}
}
// 3) For SENSOR_LANDSCAPE possibly pulse, if 90↔270 flip
if (pref == QuickSettings.OrientationPreference.SensorLandscape && old != null && rot != null) {
val isSideFlip = (old == Surface.ROTATION_90 && rot == Surface.ROTATION_270) ||
(old == Surface.ROTATION_270 && rot == Surface.ROTATION_90)
if (isSideFlip) doOrientationPulse(rot)
}
}
}
private fun deg(r: Int?): Int = when (r) {
Surface.ROTATION_0 -> 0
Surface.ROTATION_90 -> 90
Surface.ROTATION_180 -> 180
Surface.ROTATION_270 -> 270
else -> -1
}
private fun doOrientationPulse(currentRot: Int) {
if (pulsingOrientation) return
pulsingOrientation = true
// Short lock on the target page (instead of portrait intermediate step; prevents flickering)
val lock = if (currentRot == Surface.ROTATION_90)
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
else
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
try { requestedOrientation = lock } catch (_: Throwable) {}
handler.post {
if (isGameRunning) {
try { KenjinxNative.setSurfaceRotationByAndroidRotation(currentRot) } catch (_: Throwable) {}
try { mainViewModel?.gameHost?.onOrientationOrSizeChanged(currentRot) } catch (_: Throwable) {}
}
}
// After a short time back to SENSOR_LANDSCAPE
handler.postDelayed({
try { requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE } catch (_: Throwable) {}
handler.post {
if (isGameRunning) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
KenjinxNative.setSurfaceRotationByAndroidRotation(display?.rotation)
}
} catch (_: Throwable) {}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
mainViewModel?.gameHost?.onOrientationOrSizeChanged(display?.rotation)
}
} catch (_: Throwable) {}
}
}
pulsingOrientation = false
}, 250)
}
companion object {
var mainViewModel: MainViewModel? = null
var AppPath: String = ""
@ -79,7 +176,6 @@ class MainActivity : BaseActivity() {
private fun initialize() {
if (_isInit) return
val appPath: String = AppPath
var quickSettings = QuickSettings(this)
@ -114,7 +210,6 @@ class MainActivity : BaseActivity() {
}
AppPath = this.getExternalFilesDir(null)!!.absolutePath
initialize()
window.attributes.layoutInDisplayCutoutMode =
@ -127,15 +222,16 @@ class MainActivity : BaseActivity() {
WindowInsetsControllerCompat(window, window.decorView).let { controller ->
controller.hide(WindowInsetsCompat.Type.systemBars())
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
uiHandler = UiHandler()
displayManager = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
mainViewModel = MainViewModel(this)
mainViewModel!!.physicalControllerManager = physicalControllerManager
mainViewModel!!.motionSensorManager = motionSensorManager
mainViewModel!!.refreshFirmwareVersion()
mainViewModel?.apply {
@ -152,6 +248,9 @@ class MainActivity : BaseActivity() {
}
storedIntent = intent
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Log.d(TAG_ROT, "onCreate: initial display.rotation=${display?.rotation}${deg(display?.rotation)}°")
}
}
override fun onNewIntent(intent: Intent) {
@ -171,9 +270,7 @@ class MainActivity : BaseActivity() {
@SuppressLint("RestrictedApi")
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
event.apply {
if (physicalControllerManager.onKeyEvent(this)) return true
}
event.apply { if (physicalControllerManager.onKeyEvent(this)) return true }
return super.dispatchKeyEvent(event)
}
@ -185,9 +282,7 @@ class MainActivity : BaseActivity() {
override fun onStop() {
super.onStop()
isActive = false
if (isGameRunning) {
mainViewModel?.performanceManager?.setTurboMode(false)
}
if (isGameRunning) mainViewModel?.performanceManager?.setTurboMode(false)
}
override fun onResume() {
@ -195,21 +290,24 @@ class MainActivity : BaseActivity() {
// Reapply alignment if necessary
applyOrientationPreference()
// Enable display listener
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
lastKnownRotation = display?.rotation
Log.d(TAG_ROT, "onResume: display.rotation=${display?.rotation}${deg(display?.rotation)}°")
}
try { displayManager.registerDisplayListener(displayListener, handler) } catch (_: Throwable) {}
handler.postDelayed(delayedHandleIntent, 10)
isActive = true
if (isGameRunning) {
if (QuickSettings(this).enableMotion) motionSensorManager.register()
}
if (isGameRunning && QuickSettings(this).enableMotion) motionSensorManager.register()
}
override fun onPause() {
super.onPause()
isActive = false
if (isGameRunning) {
mainViewModel?.performanceManager?.setTurboMode(false)
}
if (isGameRunning) mainViewModel?.performanceManager?.setTurboMode(false)
motionSensorManager.unregister()
try { displayManager.unregisterDisplayListener(displayListener) } catch (_: Throwable) {}
}
private fun handleIntent() {
@ -224,7 +322,6 @@ class MainActivity : BaseActivity() {
if (documentFile != null) {
val gameModel = GameModel(documentFile, this)
gameModel.getGameInfo()
mainViewModel?.loadGameModel?.value = gameModel
mainViewModel?.bootPath?.value = "gameItem_${gameModel.titleName}"
@ -238,6 +335,13 @@ class MainActivity : BaseActivity() {
private fun applyOrientationPreference() {
val pref = QuickSettings(this).orientationPreference
requestedOrientation = pref.value
val rot = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
this.display?.rotation
} else {
TODO("VERSION.SDK_INT < R")
}
Log.d(TAG_ROT, "applyOrientationPreference: rot=$rot${deg(rot)}°, pref=${pref.name}")
try { KenjinxNative.setSurfaceRotationByAndroidRotation(rot) } catch (_: Throwable) {}
}
fun shutdownAndRestart() {
@ -245,7 +349,6 @@ class MainActivity : BaseActivity() {
val intent = packageManager.getLaunchIntentForPackage(packageName)
val componentName = intent?.component
val restartIntent = Intent.makeRestartActivityTask(componentName)
mainViewModel?.let { it.performanceManager?.setTurboMode(false) }
startActivity(restartIntent)
Runtime.getRuntime().exit(0)

View file

@ -2,6 +2,7 @@ package org.kenjinx.android.views
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Build
import android.provider.DocumentsContract
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@ -43,7 +44,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@ -259,11 +259,21 @@ class SettingViews {
selectedOrientation = orientationPref.value,
onOrientationSelected = { sel ->
orientationPref.value = sel
// Save and use immediately
val qs = QuickSettings(mainViewModel.activity)
qs.orientationPreference = sel
qs.save()
mainViewModel.activity.requestedOrientation = sel.value
// 1) Set activity alignment
val act = mainViewModel.activity
act.requestedOrientation = sel.value
// 2) Submit rotation/size immediately to the rendering
val rot = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
act.display?.rotation
} else {
TODO("VERSION.SDK_INT < R")
}
mainViewModel.gameHost?.onOrientationOrSizeChanged(rot)
}
)

View file

@ -24,6 +24,14 @@ namespace LibKenjinx
private static long _surfacePtr;
private static long _window = 0;
// Remembers the last set renderer size (for the jiggle)
private static int _lastRenderWidth = 0;
private static int _lastRenderHeight = 0;
// NEW: Rotation Debounce + Pending Buffer
private static int _lastRotationDegrees = -1;
private static int _pendingRotationDegrees = -1;
public static VulkanLoader? VulkanLoader { get; private set; }
[DllImport("libkenjinxjni")]
@ -310,6 +318,30 @@ namespace LibKenjinx
var result = surfaceExtension.CreateAndroidSurface(new Instance(instance), createInfo, null, out var surface);
// If a rotation was applied before the surface was created → apply it now
if (_window != 0 && _pendingRotationDegrees != -1)
{
try
{
int t = _pendingRotationDegrees switch
{
0 => 0, // IDENTITY
90 => 4, // ROTATE_90
180 => 3, // ROTATE_180 (H|V mirror)
270 => 7, // ROTATE_270 (ROT_90 | H|V)
_ => 0,
};
setCurrentTransform(_window, t);
Logger.Trace?.Print(LogClass.Application, $"[JNI] Apply pending SurfaceTransform {_pendingRotationDegrees}° (t={t}, window=0x{_window:x})");
_lastRotationDegrees = _pendingRotationDegrees;
_pendingRotationDegrees = -1;
}
catch (Exception ex)
{
Logger.Warning?.Print(LogClass.Application, $"Apply pending transform failed: {ex}");
}
}
return (nint)surface.Handle;
}
@ -322,7 +354,9 @@ namespace LibKenjinx
[UnmanagedCallersOnly(EntryPoint = "graphicsRendererSetSize")]
public static void JnaSetRendererSizeNative(int width, int height)
{
Logger.Trace?.Print(LogClass.Application, "Jni Function Call");
Logger.Trace?.Print(LogClass.Application, $"graphicsRendererSetSize -> {width}x{height}");
_lastRenderWidth = width;
_lastRenderHeight = height;
Renderer?.Window?.SetSize(width, height);
}
@ -560,6 +594,100 @@ namespace LibKenjinx
CloseUser(userId);
}
// --- Window Handle Update (Android) ---
[UnmanagedCallersOnly(EntryPoint = "deviceSetWindowHandle")]
public static void JniSetWindowHandle(long handle)
{
_window = handle;
Logger.Trace?.Print(Ryujinx.Common.Logging.LogClass.Application,
$"Window handle updated: 0x{handle:X}");
}
// --- Surface Rotation Bridge (Android) ---
[UnmanagedCallersOnly(EntryPoint = "deviceSetSurfaceRotation")]
public static void JniDeviceSetSurfaceRotation(int degrees)
{
try
{
// Normalize
degrees = degrees switch { 0 => 0, 90 => 90, 180 => 180, 270 => 270, _ => 0 };
if (degrees == _lastRotationDegrees)
{
Logger.Trace?.Print(LogClass.Application, $"[JNI] SurfaceTransform unchanged ({degrees}°), skip");
return;
}
// CORRECT bitmask mapping according to NDK:
// 0 -> 0 (IDENTITY)
// 90 -> 4 (ROTATE_90)
// 180 -> 3 (H|V mirror == 180°)
// 270 -> 7 (ROTATE_270 == ROT_90 | H|V)
int transform = degrees switch
{
0 => 0,
90 => 4,
180 => 3,
270 => 7,
_ => 0
};
if (_window != 0)
{
setCurrentTransform(_window, transform);
_lastRotationDegrees = degrees;
Logger.Trace?.Print(LogClass.Application, $"[JNI] SurfaceTransform -> {degrees}° (t={transform}, window=0x{_window:x})");
}
else
{
_pendingRotationDegrees = degrees; // apply later (see createSurfaceFunc)
Logger.Warning?.Print(LogClass.Application, $"[JNI] deviceSetSurfaceRotation: _window == 0 (pending {degrees}°)");
}
}
catch (Exception ex)
{
Logger.Warning?.Print(LogClass.Application, $"deviceSetSurfaceRotation failed: {ex}");
}
}
// --- Vulkan/GL: Swapchain/Surface Reconfiguration via Size Jiggle ---
[UnmanagedCallersOnly(EntryPoint = "deviceRecreateSwapchain")]
public static void JniDeviceRecreateSwapchain()
{
try
{
if (Renderer?.Window == null)
{
Logger.Warning?.Print(LogClass.Application, "[JNI] deviceRecreateSwapchain: Renderer.Window == null");
return;
}
int w = _lastRenderWidth;
int h = _lastRenderHeight;
if (w > 0 && h > 0)
{
int jiggleW = w;
int jiggleH = h;
if (w <= h) jiggleW = Math.Max(1, w - 1); else jiggleH = Math.Max(1, h - 1);
Logger.Trace?.Print(LogClass.Application, $"[JNI] deviceRecreateSwapchain: jiggle {jiggleW}x{jiggleH} -> {w}x{h}");
Renderer.Window.SetSize(jiggleW, jiggleH);
Renderer.Window.SetSize(w, h);
}
else
{
Logger.Trace?.Print(LogClass.Application, "[JNI] deviceRecreateSwapchain: unknown last size -> 1x1 -> 2x2 jiggle");
Renderer.Window.SetSize(1, 1);
Renderer.Window.SetSize(2, 2);
}
}
catch (Exception ex)
{
Logger.Error?.Print(LogClass.Application, $"deviceRecreateSwapchain failed: {ex}");
}
}
}
internal static partial class Logcat