diff --git a/README.md b/README.md index f02b7d7..1dc4890 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,13 @@ FiiO JM21(Android 13) 물리 키 전달 경로를 검증하기 위한 테스트 ## 구현 포인트 - `MediaSessionService` 기반 포그라운드 서비스에서 미디어 키 수신 +- 서비스 시작 시 앱 내부 저장소에 무음 WAV를 생성/반복 재생해 MediaSession 활성 상태를 유지(잠금/화면OFF 수신 안정화) - `BroadcastReceiver`(`ACTION_MEDIA_BUTTON`) 보조 수신 경로 제공 - `Activity.dispatchKeyEvent` 로 foreground 수신도 함께 기록 - 수집 필드: timestamp, keyCode, action, repeatCount, longPress, eventTime, downTime, deviceId, flags - 환경 필드: screen on/off, lock 상태, app foreground/background, service 상태, media session 활성 상태 - 로그 제어: 시작/정지/초기화/CSV 저장 +- MP3 파일 열기/재생/±5초 탐색(수동 탐색 테스트용) ## 대상 키 - `KEYCODE_VOLUME_UP` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 17c06ed..006fb49 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { implementation("androidx.media3:media3-session:1.4.1") implementation("androidx.media3:media3-exoplayer:1.4.1") + implementation("androidx.media:media:1.7.0") debugImplementation("androidx.compose.ui:ui-tooling") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7ed0cda..fa6d49e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,7 +8,7 @@ diff --git a/app/src/main/assets/images/NOW_SCREEN/DESIGN.md b/app/src/main/assets/images/NOW_SCREEN/DESIGN.md new file mode 100644 index 0000000..ed57228 --- /dev/null +++ b/app/src/main/assets/images/NOW_SCREEN/DESIGN.md @@ -0,0 +1,175 @@ +--- +name: Sonic Ember +colors: + surface: '#131313' + surface-dim: '#131313' + surface-bright: '#393939' + surface-container-lowest: '#0e0e0e' + surface-container-low: '#1c1b1b' + surface-container: '#201f1f' + surface-container-high: '#2a2a2a' + surface-container-highest: '#353534' + on-surface: '#e5e2e1' + on-surface-variant: '#ddc1ae' + inverse-surface: '#e5e2e1' + inverse-on-surface: '#313030' + outline: '#a48c7a' + outline-variant: '#564334' + surface-tint: '#ffb77d' + primary: '#ffb77d' + on-primary: '#4d2600' + primary-container: '#ff8c00' + on-primary-container: '#623200' + inverse-primary: '#904d00' + secondary: '#c8c6c5' + on-secondary: '#303030' + secondary-container: '#474746' + on-secondary-container: '#b7b5b4' + tertiary: '#ffb866' + on-tertiary: '#482900' + tertiary-container: '#e9982e' + on-tertiary-container: '#5b3600' + error: '#ffb4ab' + on-error: '#690005' + error-container: '#93000a' + on-error-container: '#ffdad6' + primary-fixed: '#ffdcc3' + primary-fixed-dim: '#ffb77d' + on-primary-fixed: '#2f1500' + on-primary-fixed-variant: '#6e3900' + secondary-fixed: '#e4e2e1' + secondary-fixed-dim: '#c8c6c5' + on-secondary-fixed: '#1b1c1c' + on-secondary-fixed-variant: '#474746' + tertiary-fixed: '#ffddba' + tertiary-fixed-dim: '#ffb866' + on-tertiary-fixed: '#2b1700' + on-tertiary-fixed-variant: '#673d00' + background: '#131313' + on-background: '#e5e2e1' + surface-variant: '#353534' +typography: + headline-xl: + fontFamily: Be Vietnam Pro + fontSize: 40px + fontWeight: '700' + lineHeight: 48px + letterSpacing: -0.02em + headline-lg: + fontFamily: Be Vietnam Pro + fontSize: 32px + fontWeight: '700' + lineHeight: 40px + letterSpacing: -0.01em + headline-md: + fontFamily: Be Vietnam Pro + fontSize: 24px + fontWeight: '600' + lineHeight: 32px + body-lg: + fontFamily: Be Vietnam Pro + fontSize: 18px + fontWeight: '400' + lineHeight: 28px + body-md: + fontFamily: Be Vietnam Pro + fontSize: 16px + fontWeight: '400' + lineHeight: 24px + label-md: + fontFamily: Be Vietnam Pro + fontSize: 14px + fontWeight: '600' + lineHeight: 20px + letterSpacing: 0.05em + label-sm: + fontFamily: Be Vietnam Pro + fontSize: 12px + fontWeight: '500' + lineHeight: 16px + letterSpacing: 0.03em +rounded: + sm: 0.25rem + DEFAULT: 0.5rem + md: 0.75rem + lg: 1rem + xl: 1.5rem + full: 9999px +spacing: + base: 4px + xs: 8px + sm: 16px + md: 24px + lg: 40px + xl: 64px + gutter: 20px + margin-mobile: 16px + margin-desktop: 32px +--- + +## Brand & Style + +The brand personality of the design system is immersive, high-energy, and sophisticated. It is tailored for audiophiles and casual listeners alike who seek a premium, late-night listening experience. The UI evokes the feeling of a dark, high-end recording studio—intimate yet technologically advanced. + +The design style combines **Minimalism** with **Glassmorphism**. By using deep charcoal foundations paired with translucent overlays, the system focuses the user's attention entirely on the album art and the music. The interaction model is fluid, utilizing subtle glows and motion to mimic the warmth of analog vacuum tubes and modern digital precision. + +## Colors + +The palette is centered on a high-energy primary orange against a void-like charcoal background. + +- **Primary (#FF8C00):** Used sparingly for critical interaction points like play buttons, progress indicators, and active states. +- **Secondary (#282828):** Employed for elevated surfaces, such as card backgrounds or hover states, to provide depth without breaking the dark aesthetic. +- **Tertiary (#FFAB40):** A lighter, desaturated orange for hover states on primary elements or subtle iconography. +- **Neutral (#121212):** The true-dark canvas that ensures maximum contrast for text and vibrant album artwork. +- **Text:** Pure white (#FFFFFF) for headlines and high-emphasis labels; Medium Grey (#B3B3B3) for secondary metadata and body text. + +## Typography + +This design system utilizes **Be Vietnam Pro** for its contemporary, friendly, yet clean aesthetic. The typeface provides excellent readability in low-light environments. + +Headlines use heavy weights and tight letter-spacing to create a bold, editorial feel for artist names and playlist titles. Labels utilize uppercase styling with slight tracking for utility elements like timestamps or category headers. All typography scales are optimized for a clear information hierarchy, ensuring track titles remain the focal point. + +## Layout & Spacing + +The design system employs a **Fluid Grid** model with a base-4 rhythm. On desktop, a 12-column grid is used for browsing, while the "Now Playing" view adopts a centered, focused layout. + +Spacing is generous to prevent the UI from feeling cluttered. Gutters are fixed at 20px to maintain a breathable rhythm between album covers. Lateral margins scale from 16px on mobile to 32px on larger viewports. Consistent vertical rhythm is maintained by using the "md" (24px) spacing unit for most section headers and list groups. + +## Elevation & Depth + +Visual hierarchy is established through **Tonal Layers** and **Glassmorphism**. Because the background is a deep charcoal (#121212), elevation is conveyed by lightening the surface color rather than using traditional shadows. + +- **Level 0:** Base background (#121212). +- **Level 1:** Navigation bars and sidebars using a semi-transparent blur (Backdrop Filter: 20px) to allow album art colors to bleed through subtly. +- **Level 2:** Player controls and cards, using the Secondary color (#282828). +- **Accents:** Active elements like the progress bar thumb or play button feature a soft orange outer glow (Box Shadow: 0 0 15px rgba(255, 140, 0, 0.4)) to simulate illumination. + +## Shapes + +The design system follows a **Rounded** shape language to evoke a modern, tactile feel. + +- **Standard Elements:** Buttons, inputs, and track cards use a 0.5rem radius. +- **Containers:** Sidebars and main content areas use "rounded-xl" (1.5rem) when nested. +- **Media:** Album artwork maintains a 1rem radius to soften the visual impact of the grid. +- **Interactive:** Search bars and primary action buttons utilize the pill-shape style for maximum tap-target friendliness and visual distinction. + +## Components + +**Buttons:** +- Primary: Pill-shaped, #FF8C00 background with white text. +- Secondary: Ghost style with #FFFFFF borders and a subtle blur backdrop. + +**Progress Bars:** +- Rail: 4px height, #282828 background. +- Fill: #FF8C00 with a 10px blurred "glow" at the leading edge. +- Thumb: Appears only on hover/scrub, 12px circle in #FFFFFF. + +**Cards:** +- Artwork-centric. Titles are placed directly below the image. Hovering over a card reveals a floating play icon with a glassmorphic background. + +**Lists:** +- Track lists use high-contrast white for titles and #B3B3B3 for artists/durations. Rows highlight with a #282828 background on hover. + +**Additional Components:** +- **Visualizer:** A waveform component that reacts to audio, utilizing the Primary color with varying opacities. +- **Now Playing Bar:** A persistent bottom bar with a blurred background and a condensed view of the current track's metadata. \ No newline at end of file diff --git a/app/src/main/assets/images/NOW_SCREEN/code.html b/app/src/main/assets/images/NOW_SCREEN/code.html new file mode 100644 index 0000000..f9993f8 --- /dev/null +++ b/app/src/main/assets/images/NOW_SCREEN/code.html @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + +
+
+ +

Now Playing

+ +
+
+
+ +
+
+
+
+ +
+
+Vibrant abstract cover art with neon orange and purple lighting effects +
+ +
+

When the city lights go down

+

And the silence starts to sound

+

I'm still searching for the rhythm...

+
+ +
+
+

After Hours

+

Chrome Echoes

+
+
+ +
+
+
+ +
+
+
+1:42 +3:55 +
+
+ +
+ +
+ + + +
+ +
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/app/src/main/assets/images/NOW_SCREEN/screen.png b/app/src/main/assets/images/NOW_SCREEN/screen.png new file mode 100644 index 0000000..700f7b7 Binary files /dev/null and b/app/src/main/assets/images/NOW_SCREEN/screen.png differ diff --git a/app/src/main/assets/images/PLAYLIST/DESIGN.md b/app/src/main/assets/images/PLAYLIST/DESIGN.md new file mode 100644 index 0000000..ed57228 --- /dev/null +++ b/app/src/main/assets/images/PLAYLIST/DESIGN.md @@ -0,0 +1,175 @@ +--- +name: Sonic Ember +colors: + surface: '#131313' + surface-dim: '#131313' + surface-bright: '#393939' + surface-container-lowest: '#0e0e0e' + surface-container-low: '#1c1b1b' + surface-container: '#201f1f' + surface-container-high: '#2a2a2a' + surface-container-highest: '#353534' + on-surface: '#e5e2e1' + on-surface-variant: '#ddc1ae' + inverse-surface: '#e5e2e1' + inverse-on-surface: '#313030' + outline: '#a48c7a' + outline-variant: '#564334' + surface-tint: '#ffb77d' + primary: '#ffb77d' + on-primary: '#4d2600' + primary-container: '#ff8c00' + on-primary-container: '#623200' + inverse-primary: '#904d00' + secondary: '#c8c6c5' + on-secondary: '#303030' + secondary-container: '#474746' + on-secondary-container: '#b7b5b4' + tertiary: '#ffb866' + on-tertiary: '#482900' + tertiary-container: '#e9982e' + on-tertiary-container: '#5b3600' + error: '#ffb4ab' + on-error: '#690005' + error-container: '#93000a' + on-error-container: '#ffdad6' + primary-fixed: '#ffdcc3' + primary-fixed-dim: '#ffb77d' + on-primary-fixed: '#2f1500' + on-primary-fixed-variant: '#6e3900' + secondary-fixed: '#e4e2e1' + secondary-fixed-dim: '#c8c6c5' + on-secondary-fixed: '#1b1c1c' + on-secondary-fixed-variant: '#474746' + tertiary-fixed: '#ffddba' + tertiary-fixed-dim: '#ffb866' + on-tertiary-fixed: '#2b1700' + on-tertiary-fixed-variant: '#673d00' + background: '#131313' + on-background: '#e5e2e1' + surface-variant: '#353534' +typography: + headline-xl: + fontFamily: Be Vietnam Pro + fontSize: 40px + fontWeight: '700' + lineHeight: 48px + letterSpacing: -0.02em + headline-lg: + fontFamily: Be Vietnam Pro + fontSize: 32px + fontWeight: '700' + lineHeight: 40px + letterSpacing: -0.01em + headline-md: + fontFamily: Be Vietnam Pro + fontSize: 24px + fontWeight: '600' + lineHeight: 32px + body-lg: + fontFamily: Be Vietnam Pro + fontSize: 18px + fontWeight: '400' + lineHeight: 28px + body-md: + fontFamily: Be Vietnam Pro + fontSize: 16px + fontWeight: '400' + lineHeight: 24px + label-md: + fontFamily: Be Vietnam Pro + fontSize: 14px + fontWeight: '600' + lineHeight: 20px + letterSpacing: 0.05em + label-sm: + fontFamily: Be Vietnam Pro + fontSize: 12px + fontWeight: '500' + lineHeight: 16px + letterSpacing: 0.03em +rounded: + sm: 0.25rem + DEFAULT: 0.5rem + md: 0.75rem + lg: 1rem + xl: 1.5rem + full: 9999px +spacing: + base: 4px + xs: 8px + sm: 16px + md: 24px + lg: 40px + xl: 64px + gutter: 20px + margin-mobile: 16px + margin-desktop: 32px +--- + +## Brand & Style + +The brand personality of the design system is immersive, high-energy, and sophisticated. It is tailored for audiophiles and casual listeners alike who seek a premium, late-night listening experience. The UI evokes the feeling of a dark, high-end recording studio—intimate yet technologically advanced. + +The design style combines **Minimalism** with **Glassmorphism**. By using deep charcoal foundations paired with translucent overlays, the system focuses the user's attention entirely on the album art and the music. The interaction model is fluid, utilizing subtle glows and motion to mimic the warmth of analog vacuum tubes and modern digital precision. + +## Colors + +The palette is centered on a high-energy primary orange against a void-like charcoal background. + +- **Primary (#FF8C00):** Used sparingly for critical interaction points like play buttons, progress indicators, and active states. +- **Secondary (#282828):** Employed for elevated surfaces, such as card backgrounds or hover states, to provide depth without breaking the dark aesthetic. +- **Tertiary (#FFAB40):** A lighter, desaturated orange for hover states on primary elements or subtle iconography. +- **Neutral (#121212):** The true-dark canvas that ensures maximum contrast for text and vibrant album artwork. +- **Text:** Pure white (#FFFFFF) for headlines and high-emphasis labels; Medium Grey (#B3B3B3) for secondary metadata and body text. + +## Typography + +This design system utilizes **Be Vietnam Pro** for its contemporary, friendly, yet clean aesthetic. The typeface provides excellent readability in low-light environments. + +Headlines use heavy weights and tight letter-spacing to create a bold, editorial feel for artist names and playlist titles. Labels utilize uppercase styling with slight tracking for utility elements like timestamps or category headers. All typography scales are optimized for a clear information hierarchy, ensuring track titles remain the focal point. + +## Layout & Spacing + +The design system employs a **Fluid Grid** model with a base-4 rhythm. On desktop, a 12-column grid is used for browsing, while the "Now Playing" view adopts a centered, focused layout. + +Spacing is generous to prevent the UI from feeling cluttered. Gutters are fixed at 20px to maintain a breathable rhythm between album covers. Lateral margins scale from 16px on mobile to 32px on larger viewports. Consistent vertical rhythm is maintained by using the "md" (24px) spacing unit for most section headers and list groups. + +## Elevation & Depth + +Visual hierarchy is established through **Tonal Layers** and **Glassmorphism**. Because the background is a deep charcoal (#121212), elevation is conveyed by lightening the surface color rather than using traditional shadows. + +- **Level 0:** Base background (#121212). +- **Level 1:** Navigation bars and sidebars using a semi-transparent blur (Backdrop Filter: 20px) to allow album art colors to bleed through subtly. +- **Level 2:** Player controls and cards, using the Secondary color (#282828). +- **Accents:** Active elements like the progress bar thumb or play button feature a soft orange outer glow (Box Shadow: 0 0 15px rgba(255, 140, 0, 0.4)) to simulate illumination. + +## Shapes + +The design system follows a **Rounded** shape language to evoke a modern, tactile feel. + +- **Standard Elements:** Buttons, inputs, and track cards use a 0.5rem radius. +- **Containers:** Sidebars and main content areas use "rounded-xl" (1.5rem) when nested. +- **Media:** Album artwork maintains a 1rem radius to soften the visual impact of the grid. +- **Interactive:** Search bars and primary action buttons utilize the pill-shape style for maximum tap-target friendliness and visual distinction. + +## Components + +**Buttons:** +- Primary: Pill-shaped, #FF8C00 background with white text. +- Secondary: Ghost style with #FFFFFF borders and a subtle blur backdrop. + +**Progress Bars:** +- Rail: 4px height, #282828 background. +- Fill: #FF8C00 with a 10px blurred "glow" at the leading edge. +- Thumb: Appears only on hover/scrub, 12px circle in #FFFFFF. + +**Cards:** +- Artwork-centric. Titles are placed directly below the image. Hovering over a card reveals a floating play icon with a glassmorphic background. + +**Lists:** +- Track lists use high-contrast white for titles and #B3B3B3 for artists/durations. Rows highlight with a #282828 background on hover. + +**Additional Components:** +- **Visualizer:** A waveform component that reacts to audio, utilizing the Primary color with varying opacities. +- **Now Playing Bar:** A persistent bottom bar with a blurred background and a condensed view of the current track's metadata. \ No newline at end of file diff --git a/app/src/main/assets/images/PLAYLIST/code.html b/app/src/main/assets/images/PLAYLIST/code.html new file mode 100644 index 0000000..7b89bf2 --- /dev/null +++ b/app/src/main/assets/images/PLAYLIST/code.html @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + +
+
+keyboard_arrow_down +

Now Playing

+
+
+more_vert +
+
+
+
+
+# + +Title + +schedule +
+
+1 + +
+Vortex Echoes +Lumina Sound +
+ +3:42 +
+
+
+equalizer +
+ +
+Neon Drift +Chrome Velvet +
+ +4:15 +
+
+3 + +
+Shadow Work +The Architect +
+ +5:01 +
+
+4 + +
+Frequency Shift +Null Space +
+ +3:58 +
+
+5 + +
+Velvet Horizon +Dawn Patrol +
+ +4:30 +
+
+
+ + \ No newline at end of file diff --git a/app/src/main/assets/images/PLAYLIST/screen.png b/app/src/main/assets/images/PLAYLIST/screen.png new file mode 100644 index 0000000..17ca66e Binary files /dev/null and b/app/src/main/assets/images/PLAYLIST/screen.png differ diff --git a/app/src/main/java/com/example/keyeventlogger/DeviceStateProvider.kt b/app/src/main/java/com/example/keyeventlogger/DeviceStateProvider.kt index c070df6..e4a4767 100644 --- a/app/src/main/java/com/example/keyeventlogger/DeviceStateProvider.kt +++ b/app/src/main/java/com/example/keyeventlogger/DeviceStateProvider.kt @@ -11,12 +11,18 @@ import kotlinx.coroutines.flow.asStateFlow object DeviceStateProvider { private val _mediaSessionActive = MutableStateFlow(false) val mediaSessionActive: StateFlow = _mediaSessionActive.asStateFlow() + private val _serviceRunning = MutableStateFlow(false) + val serviceRunning: StateFlow = _serviceRunning.asStateFlow() fun setMediaSessionActive(active: Boolean) { _mediaSessionActive.value = active } - fun snapshot(context: Context, serviceRunning: Boolean): EnvironmentSnapshot { + fun setServiceRunning(running: Boolean) { + _serviceRunning.value = running + } + + fun snapshot(context: Context): EnvironmentSnapshot { val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager val appForeground = ProcessLifecycleOwner @@ -29,7 +35,7 @@ object DeviceStateProvider { screenOn = pm.isInteractive, locked = keyguardManager.isDeviceLocked, appInForeground = appForeground, - serviceRunning = serviceRunning, + serviceRunning = serviceRunning.value, mediaSessionActive = mediaSessionActive.value ) } diff --git a/app/src/main/java/com/example/keyeventlogger/KeyLoggerService.kt b/app/src/main/java/com/example/keyeventlogger/KeyLoggerService.kt index ab61e2d..6362dfb 100644 --- a/app/src/main/java/com/example/keyeventlogger/KeyLoggerService.kt +++ b/app/src/main/java/com/example/keyeventlogger/KeyLoggerService.kt @@ -4,31 +4,69 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent +import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Build import android.view.KeyEvent import androidx.core.app.NotificationCompat +import androidx.media3.common.AudioAttributes import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession -import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSession.ControllerInfo +import androidx.media3.session.MediaSessionService +import androidx.media.app.NotificationCompat.MediaStyle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch class KeyLoggerService : MediaSessionService() { - private var player: ExoPlayer? = null + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var mediaSession: MediaSession? = null + private var stateJob: Job? = null + private var currentQueue: List = emptyList() + private lateinit var player: ExoPlayer override fun onCreate() { super.onCreate() + serviceInstance = this createChannel() - startForeground(101, buildNotification()) + DeviceStateProvider.setServiceRunning(true) player = ExoPlayer.Builder(this).build().apply { - setMediaItem(MediaItem.fromUri("https://example.com/silence.mp3")) - prepare() + val audioAttr = AudioAttributes.Builder() + .setUsage(androidx.media3.common.C.USAGE_MEDIA) + .setContentType(androidx.media3.common.C.AUDIO_CONTENT_TYPE_MUSIC) + .build() + setAudioAttributes(audioAttr, true) + repeatMode = Player.REPEAT_MODE_OFF + addListener(object : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + updatePlayerState() + if ( + events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION) || + events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED) || + events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED) + ) { + updateNotification() + } + } + }) } - mediaSession = MediaSession.Builder(this, player!!) + registerKeyHandlers() + startForeground(NOTIFICATION_ID, buildNotification()) + + mediaSession = MediaSession.Builder(this, player) .setCallback(object : MediaSession.Callback { override fun onMediaButtonEvent( session: MediaSession, @@ -38,11 +76,15 @@ class KeyLoggerService : MediaSessionService() { @Suppress("DEPRECATION") val event = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as? KeyEvent if (event != null && isTargetKey(event.keyCode)) { + val snapshot = DeviceStateProvider.snapshot(this@KeyLoggerService) KeyEventLogRepository.append( event = event, - snapshot = DeviceStateProvider.snapshot(this@KeyLoggerService, serviceRunning = true), + snapshot = snapshot, source = "MediaSessionService" ) + if (handleMediaKey(event, snapshot)) { + return true + } } return super.onMediaButtonEvent(session, controller, intent) } @@ -50,28 +92,173 @@ class KeyLoggerService : MediaSessionService() { .build() DeviceStateProvider.setMediaSessionActive(true) + pendingQueue?.let { setPlaylistInternal(it, pendingStartIndex) } + startStateLoop() + updatePlayerState() } override fun onGetSession(controllerInfo: ControllerInfo): MediaSession? = mediaSession override fun onDestroy() { + if (serviceInstance === this) { + serviceInstance = null + } + ScreenOffSeekController.setSeekByHandler(null) + ScreenOffSeekController.setTapHandler(null) + DeviceStateProvider.setServiceRunning(false) DeviceStateProvider.setMediaSessionActive(false) - player?.release() + stateJob?.cancel() + _playerState.value = _playerState.value.copy( + serviceRunning = false, + isPlaying = false, + wantsToPlay = false, + positionMs = 0L + ) mediaSession?.release() + if (::player.isInitialized) { + player.release() + } mediaSession = null - player = null super.onDestroy() } + private fun registerKeyHandlers() { + ScreenOffSeekController.setSeekByHandler { deltaMs -> seekByInternal(deltaMs) } + ScreenOffSeekController.setTapHandler { keyCode -> + when (keyCode) { + KeyEvent.KEYCODE_MEDIA_NEXT -> skipNextInternal() + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> skipPreviousInternal() + } + } + } + + private fun handleMediaKey(event: KeyEvent, snapshot: EnvironmentSnapshot): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) { + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + togglePlayPauseInternal() + } + return event.action == KeyEvent.ACTION_DOWN || event.action == KeyEvent.ACTION_UP + } + return ScreenOffSeekController.handle(event, snapshot) + } + + private fun setPlaylistInternal(tracks: List, startIndex: Int = 0) { + pendingQueue = tracks + pendingStartIndex = startIndex + currentQueue = tracks + if (tracks.isEmpty()) { + player.clearMediaItems() + updatePlayerState() + updateNotification() + return + } + + val safeStartIndex = startIndex.coerceIn(0, tracks.lastIndex) + player.setMediaItems(tracks.map { it.toMediaItem() }, safeStartIndex, 0L) + player.prepare() + player.playWhenReady = true + updatePlayerState() + updateNotification() + } + + private fun togglePlayPauseInternal() { + if (player.isPlaying) { + player.pause() + } else { + player.play() + } + updatePlayerState() + updateNotification() + } + + private fun seekByInternal(deltaMs: Long) { + if (player.mediaItemCount == 0) return + val duration = player.duration.takeIf { it > 0 } ?: Long.MAX_VALUE + val newPosition = (player.currentPosition + deltaMs).coerceIn(0L, duration) + player.seekTo(newPosition) + updatePlayerState() + } + + private fun seekToInternal(positionMs: Long) { + if (player.mediaItemCount == 0) return + val duration = player.duration.takeIf { it > 0 } ?: Long.MAX_VALUE + player.seekTo(positionMs.coerceIn(0L, duration)) + updatePlayerState() + } + + private fun skipNextInternal() { + if (player.mediaItemCount == 0) return + if (player.hasNextMediaItem()) { + player.seekToNextMediaItem() + } else { + player.seekTo(0) + } + updatePlayerState() + updateNotification() + } + + private fun skipPreviousInternal() { + if (player.mediaItemCount == 0) return + if (player.hasPreviousMediaItem()) { + player.seekToPreviousMediaItem() + } else { + player.seekTo(0) + } + updatePlayerState() + updateNotification() + } + + private fun startStateLoop() { + stateJob?.cancel() + stateJob = serviceScope.launch { + while (true) { + updatePlayerState() + delay(400) + } + } + } + + private fun updatePlayerState() { + val itemCount = player.mediaItemCount + val currentTrack = currentQueue.getOrNull(player.currentMediaItemIndex) + val currentTitle = player.currentMediaItem + ?.mediaMetadata + ?.title + ?.toString() + val positionMs = player.currentPosition.coerceAtLeast(0L) + _playerState.value = PlayerUiState( + serviceRunning = true, + isPlaying = player.isPlaying, + wantsToPlay = player.playWhenReady, + title = currentTitle, + artworkUri = currentTrack?.artworkUri, + subtitleText = currentTrack?.subtitleAt(positionMs), + hasSubtitles = currentTrack?.subtitles?.isNotEmpty() == true, + currentIndex = if (itemCount > 0) player.currentMediaItemIndex + 1 else 0, + queueSize = itemCount, + positionMs = positionMs, + durationMs = player.duration.takeIf { it > 0 } ?: 0L, + queue = currentQueue + ) + } + + private fun updateNotification() { + val manager = getSystemService(NotificationManager::class.java) + manager.notify(NOTIFICATION_ID, buildNotification()) + } + private fun buildNotification(): Notification { val intent = Intent(this, MainActivity::class.java) val pi = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) - return NotificationCompat.Builder(this, "keylogger_channel") - .setContentTitle("JM21 Key Logger") - .setContentText("MediaSession key capture is active") + val state = _playerState.value + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(state.title ?: "AMSR Player") + .setContentText("${state.currentIndex}/${state.queueSize} ${if (state.wantsToPlay) "Playing" else "Paused"}") .setSmallIcon(android.R.drawable.ic_media_play) .setContentIntent(pi) - .setOngoing(true) + .setOngoing(state.isPlaying) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setStyle(MediaStyle().setMediaSession(mediaSession?.sessionCompatToken)) .build() } @@ -80,15 +267,81 @@ class KeyLoggerService : MediaSessionService() { val manager = getSystemService(NotificationManager::class.java) manager.createNotificationChannel( NotificationChannel( - "keylogger_channel", - "Key Logger", + CHANNEL_ID, + "AMSR Player", NotificationManager.IMPORTANCE_LOW ) ) } } + private fun PlayerTrack.toMediaItem(): MediaItem = + MediaItem.Builder() + .setUri(Uri.parse(uri)) + .setMediaMetadata( + MediaMetadata.Builder().apply { + setTitle(title) + artworkUri?.let { setArtworkUri(Uri.parse(it)) } + }.build() + ) + .build() + + private fun PlayerTrack.subtitleAt(positionMs: Long): String? = + subtitles.firstOrNull { positionMs in it.startMs..it.endMs }?.text + companion object { + private const val CHANNEL_ID = "amsr_player_channel" + private const val NOTIFICATION_ID = 101 + + private val _playerState = MutableStateFlow(PlayerUiState()) + val playerState: StateFlow = _playerState.asStateFlow() + + private var serviceInstance: KeyLoggerService? = null + private var pendingQueue: List? = null + private var pendingStartIndex: Int = 0 + + fun start(context: Context) { + context.startForegroundService(Intent(context, KeyLoggerService::class.java)) + } + + fun setPlaylist(tracks: List, startIndex: Int = 0) { + pendingQueue = tracks + pendingStartIndex = startIndex + serviceInstance?.setPlaylistInternal(tracks, startIndex) + ?: run { + _playerState.value = _playerState.value.copy( + queue = tracks, + queueSize = tracks.size, + currentIndex = if (tracks.isNotEmpty()) startIndex.coerceIn(0, tracks.lastIndex) + 1 else 0 + ) + } + } + + fun togglePlayPause() { + serviceInstance?.togglePlayPauseInternal() + } + + fun seekBy(deltaMs: Long) { + serviceInstance?.seekByInternal(deltaMs) + } + + fun seekTo(positionMs: Long) { + serviceInstance?.seekToInternal(positionMs) + } + + fun skipNext() { + serviceInstance?.skipNextInternal() + } + + fun skipPrevious() { + serviceInstance?.skipPreviousInternal() + } + + fun handleMediaKey(context: Context, event: KeyEvent, snapshot: EnvironmentSnapshot): Boolean { + start(context) + return serviceInstance?.handleMediaKey(event, snapshot) ?: ScreenOffSeekController.handle(event, snapshot) + } + fun isTargetKey(keyCode: Int): Boolean = keyCode in setOf( KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, diff --git a/app/src/main/java/com/example/keyeventlogger/MainActivity.kt b/app/src/main/java/com/example/keyeventlogger/MainActivity.kt index d601a74..ac1d714 100644 --- a/app/src/main/java/com/example/keyeventlogger/MainActivity.kt +++ b/app/src/main/java/com/example/keyeventlogger/MainActivity.kt @@ -1,45 +1,165 @@ package com.example.keyeventlogger +import android.Manifest import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Build import android.os.Bundle +import android.provider.DocumentsContract import android.view.KeyEvent import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.Image import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File class MainActivity : ComponentActivity() { private val uiSnapshot = MutableStateFlow(null) + private val folderBrowserState = MutableStateFlow(FolderBrowserState()) + + private val notificationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + Toast.makeText( + this, + if (granted) "Notifications enabled" else "Notifications disabled", + Toast.LENGTH_SHORT + ).show() + } + + private val pickAudioLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> + if (uri == null) return@registerForActivityResult + kotlin.runCatching { + contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + KeyLoggerService.start(this) + KeyLoggerService.setPlaylist( + listOf( + PlayerTrack( + uri = uri.toString(), + title = uri.lastPathSegment ?: "Selected MP3", + artworkUri = extractArtworkUri(uri) + ) + ), + startIndex = 0 + ) + Toast.makeText(this, "MP3 loaded", Toast.LENGTH_SHORT).show() + } + + private val pickFolderLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> + if (uri == null) return@registerForActivityResult + kotlin.runCatching { + contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + val rootDocumentId = DocumentsContract.getTreeDocumentId(uri) + openBrowserFolder( + treeUri = uri, + documentId = rootDocumentId, + path = listOf(FolderCrumb(rootDocumentId, rootFolderName(rootDocumentId))) + ) + Toast.makeText(this, "Folder opened", Toast.LENGTH_SHORT).show() + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + configureSystemBars() + requestNotificationPermissionIfNeeded() + startService() refreshSnapshotLoop() + setContent { - MaterialTheme { + MaterialTheme(colorScheme = AppDarkColorScheme) { Surface(modifier = Modifier.fillMaxSize()) { + val playerState by KeyLoggerService.playerState.collectAsState() + val browserState by folderBrowserState.asStateFlow().collectAsState() LoggerScreen( snapshot = uiSnapshot.asStateFlow().collectAsState().value, + playerState = playerState, + browserState = browserState, + onPickMp3 = { pickAudioLauncher.launch(arrayOf("audio/mpeg", "audio/*")) }, + onPickFolder = { pickFolderLauncher.launch(null) }, + onOpenFolderEntry = { entry -> + val current = folderBrowserState.value + val treeUri = current.treeUri + if (treeUri != null) { + openBrowserFolder( + treeUri = treeUri, + documentId = entry.documentId, + path = current.path + FolderCrumb(entry.documentId, entry.name) + ) + } + }, + onPlayFolderTrack = { playFolderTrack(it) }, + onUpFolder = { openParentFolder() }, + onPlayPause = { KeyLoggerService.togglePlayPause() }, + onSeekTo = { KeyLoggerService.seekTo(it) }, + onPrevious = { KeyLoggerService.skipPrevious() }, + onNext = { KeyLoggerService.skipNext() }, onStartLogging = { KeyEventLogRepository.start() }, onStopLogging = { KeyEventLogRepository.stop() }, onClear = { KeyEventLogRepository.clear() }, @@ -56,33 +176,349 @@ class MainActivity : ComponentActivity() { } override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val snapshot = DeviceStateProvider.snapshot(this) if (KeyLoggerService.isTargetKey(event.keyCode)) { KeyEventLogRepository.append( event = event, - snapshot = DeviceStateProvider.snapshot(this, serviceRunning = true), + snapshot = snapshot, source = "Activity" ) } + if (KeyLoggerService.handleMediaKey(this, event, snapshot)) { + return true + } return super.dispatchKeyEvent(event) } private fun startService() { - startForegroundService(Intent(this, KeyLoggerService::class.java)) + KeyLoggerService.start(this) + } + + private fun configureSystemBars() { + window.statusBarColor = android.graphics.Color.rgb(16, 16, 16) + window.navigationBarColor = android.graphics.Color.rgb(16, 16, 16) + WindowInsetsControllerCompat(window, window.decorView).apply { + isAppearanceLightStatusBars = false + isAppearanceLightNavigationBars = false + } + } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + if ( + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } } private fun refreshSnapshotLoop() { lifecycleScope.launch { while (true) { - uiSnapshot.value = DeviceStateProvider.snapshot(this@MainActivity, serviceRunning = true) + uiSnapshot.value = DeviceStateProvider.snapshot(this@MainActivity) delay(300) } } } + + private fun openBrowserFolder(treeUri: Uri, documentId: String, path: List) { + val entries = runCatching { + listFolderEntries(treeUri, documentId, includeSubtitles = false) + }.getOrElse { + Toast.makeText(this, "Unable to open folder", Toast.LENGTH_SHORT).show() + emptyList() + } + folderBrowserState.value = FolderBrowserState( + treeUri = treeUri, + currentDocumentId = documentId, + path = path, + entries = entries + ) + loadEntryDurations(treeUri, documentId, entries) + } + + private fun loadEntryDurations(treeUri: Uri, documentId: String, entries: List) { + val trackEntries = entries.filter { !it.isDirectory && isMp3(it.name, it.mimeType) } + if (trackEntries.isEmpty()) return + + lifecycleScope.launch { + val durationsById = withContext(Dispatchers.IO) { + trackEntries.associate { entry -> + entry.documentId to readDurationMs(entry.uri) + } + } + val current = folderBrowserState.value + if (current.treeUri != treeUri || current.currentDocumentId != documentId) return@launch + folderBrowserState.value = current.copy( + entries = current.entries.map { entry -> + entry.copy(durationMs = durationsById[entry.documentId]) + } + ) + } + } + + private fun openParentFolder() { + val current = folderBrowserState.value + val treeUri = current.treeUri ?: return + if (current.path.size <= 1) return + val parentPath = current.path.dropLast(1) + val parent = parentPath.last() + openBrowserFolder(treeUri, parent.documentId, parentPath) + } + + private fun playFolderTrack(entry: FolderEntry) { + val current = folderBrowserState.value + if (current.isLoadingPlaylist) return + val treeUri = current.treeUri ?: return + val folderDocumentId = current.currentDocumentId ?: return + folderBrowserState.value = current.copy(isLoadingPlaylist = true) + + lifecycleScope.launch { + val playlistResult = withContext(Dispatchers.IO) { + runCatching { buildFolderPlaylist(treeUri, folderDocumentId, entry) } + } + folderBrowserState.value = folderBrowserState.value.copy(isLoadingPlaylist = false) + val playlist = playlistResult.getOrElse { + Toast.makeText(this@MainActivity, "Unable to read folder", Toast.LENGTH_SHORT).show() + return@launch + } + if (playlist.tracks.isEmpty()) { + Toast.makeText(this@MainActivity, "No MP3 files found", Toast.LENGTH_SHORT).show() + return@launch + } + + KeyLoggerService.start(this@MainActivity) + KeyLoggerService.setPlaylist(playlist.tracks, playlist.startIndex) + Toast.makeText(this@MainActivity, "Playing ${playlist.selectedTitle}", Toast.LENGTH_SHORT).show() + } + } + + private fun buildFolderPlaylist( + treeUri: Uri, + folderDocumentId: String, + selectedEntry: FolderEntry + ): FolderPlaylist { + val files = listFolderEntries(treeUri, folderDocumentId, includeSubtitles = true) + val subtitlesByBaseName = files + .filter { isSrt(it.name) } + .associateBy { baseName(it.name).lowercase() } + val selectedUri = selectedEntry.uri.toString() + val tracks = files + .filter { isMp3(it.name, it.mimeType) } + .sortedBy { it.name.lowercase() } + .map { file -> + val srtFile = subtitlesByBaseName[baseName(file.name).lowercase()] + val fileUri = file.uri.toString() + PlayerTrack( + uri = fileUri, + title = file.name, + artworkUri = if (fileUri == selectedUri) extractArtworkUri(file.uri) else null, + subtitles = srtFile?.let { parseSrt(it.uri) }.orEmpty() + ) + } + val startIndex = tracks + .indexOfFirst { it.uri == selectedUri } + .takeIf { it >= 0 } + ?: 0 + return FolderPlaylist( + tracks = tracks, + startIndex = startIndex, + selectedTitle = selectedEntry.name + ) + } + + private fun listFolderEntries( + treeUri: Uri, + documentId: String, + includeSubtitles: Boolean + ): List { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, documentId) + val projection = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE + ) + val entries = mutableListOf() + + contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor -> + val idIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val nameIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val mimeIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE) + while (cursor.moveToNext()) { + val childId = cursor.getString(idIndex) + val name = cursor.getString(nameIndex) ?: childId + val mimeType = cursor.getString(mimeIndex) + val isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR + if (isDirectory || isMp3(name, mimeType) || (includeSubtitles && isSrt(name))) { + entries.add( + FolderEntry( + name = name, + uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, childId), + documentId = childId, + mimeType = mimeType, + isDirectory = isDirectory + ) + ) + } + } + } + return entries.sortedWith( + compareBy { !it.isDirectory } + .thenBy { it.name.lowercase() } + ) + } + + private fun isMp3(name: String, mimeType: String?): Boolean = + mimeType == "audio/mpeg" || name.endsWith(".mp3", ignoreCase = true) + + private fun isSrt(name: String): Boolean = + name.endsWith(".srt", ignoreCase = true) + + private fun baseName(name: String): String = + name.substringBeforeLast('.', name) + + private fun rootFolderName(documentId: String): String { + val afterVolume = documentId.substringAfter(':', documentId) + return afterVolume.substringAfterLast('/').ifBlank { + documentId.substringBefore(':').ifBlank { "Root" } + } + } + + private fun extractArtworkUri(uri: Uri): String? { + val retriever = MediaMetadataRetriever() + return runCatching { + retriever.setDataSource(this, uri) + val imageBytes = retriever.embeddedPicture ?: return@runCatching null + val file = File(cacheDir, "art_${uri.toString().hashCode()}.jpg") + file.writeBytes(imageBytes) + Uri.fromFile(file).toString() + }.getOrNull().also { + runCatching { retriever.release() } + } + } + + private fun readDurationMs(uri: Uri): Long? { + val retriever = MediaMetadataRetriever() + return runCatching { + retriever.setDataSource(this, uri) + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() + }.getOrNull().also { + runCatching { retriever.release() } + } + } + + private fun parseSrt(uri: Uri): List { + val text = contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } ?: return emptyList() + return text + .replace("\uFEFF", "") + .split(Regex("\\r?\\n\\r?\\n")) + .mapNotNull { block -> + val lines = block.lines().filter { it.isNotBlank() } + val timeLineIndex = lines.indexOfFirst { it.contains("-->") } + if (timeLineIndex < 0) return@mapNotNull null + val match = SRT_TIME_REGEX.find(lines[timeLineIndex]) ?: return@mapNotNull null + val subtitleText = lines.drop(timeLineIndex + 1).joinToString("\n").trim() + if (subtitleText.isBlank()) return@mapNotNull null + SubtitleCue( + startMs = parseSrtTime(match.groupValues[1]), + endMs = parseSrtTime(match.groupValues[2]), + text = subtitleText + ) + } + } + + private fun parseSrtTime(value: String): Long { + val parts = value.split(':', ',') + if (parts.size != 4) return 0L + val hours = parts[0].toLongOrNull() ?: 0L + val minutes = parts[1].toLongOrNull() ?: 0L + val seconds = parts[2].toLongOrNull() ?: 0L + val millis = parts[3].toLongOrNull() ?: 0L + return (((hours * 60 + minutes) * 60 + seconds) * 1000) + millis + } + + companion object { + private val SRT_TIME_REGEX = Regex("(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})") + } } +private data class FolderBrowserState( + val treeUri: Uri? = null, + val currentDocumentId: String? = null, + val path: List = emptyList(), + val entries: List = emptyList(), + val isLoadingPlaylist: Boolean = false +) + +private data class FolderCrumb( + val documentId: String, + val name: String +) + +private data class FolderEntry( + val name: String, + val uri: Uri, + val documentId: String, + val mimeType: String?, + val isDirectory: Boolean, + val durationMs: Long? = null +) + +private data class FolderPlaylist( + val tracks: List, + val startIndex: Int, + val selectedTitle: String +) + +private val AppDarkColorScheme = darkColorScheme( + background = Color(0xFF101010), + surface = Color(0xFF101010), + surfaceVariant = Color(0xFF2A2A2A), + primary = Color(0xFFFF8C00), + onPrimary = Color(0xFF3A1D00), + primaryContainer = Color(0xFFFF8C00), + onPrimaryContainer = Color(0xFF3A1D00), + secondary = Color(0xFFC8C6C5), + onSecondary = Color(0xFF303030), + secondaryContainer = Color(0xFF2A2A2A), + onSecondaryContainer = Color(0xFFECE8E5), + onBackground = Color(0xFFECE8E5), + onSurface = Color(0xFFECE8E5), + onSurfaceVariant = Color(0xFFBCA795), + outline = Color(0xFF6A584C) +) +private val NowBackground = Color(0xFF101010) +private val NowOrange = Color(0xFFFF8C00) +private val NowText = Color(0xFFECE8E5) +private val NowMutedText = Color(0xFFBCA795) +private val NowRail = Color(0xFF393939) +private val PlaylistHeaderText = Color(0xFF8E8D96) +private val PlaylistActiveSurface = Color(0xFF2A1A08) +private val PlaylistDimSurface = Color(0xFF1A1A1A) +private val NowArtGradient = Brush.linearGradient( + colors = listOf( + Color(0xFF2B1024), + Color(0xFFFF8C00), + Color(0xFF25111E), + Color(0xFF0F0F0F) + ) +) + @Composable private fun LoggerScreen( snapshot: EnvironmentSnapshot?, + playerState: PlayerUiState, + browserState: FolderBrowserState, + onPickMp3: () -> Unit, + onPickFolder: () -> Unit, + onOpenFolderEntry: (FolderEntry) -> Unit, + onPlayFolderTrack: (FolderEntry) -> Unit, + onUpFolder: () -> Unit, + onPlayPause: () -> Unit, + onSeekTo: (Long) -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit, onStartLogging: () -> Unit, onStopLogging: () -> Unit, onClear: () -> Unit, @@ -91,40 +527,552 @@ private fun LoggerScreen( onExport: () -> Unit ) { val logs by KeyEventLogRepository.logs.collectAsState() - val isLogging by KeyEventLogRepository.isLogging.collectAsState() + val keySeekStatus by ScreenOffSeekController.status.collectAsState() + var selectedTab by remember { mutableStateOf(1) } + val tabs = listOf("Playlist", "Now", "Settings") Column( modifier = Modifier .fillMaxSize() - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + .background(MaterialTheme.colorScheme.background) + .padding(12.dp) ) { - Text("상태", style = MaterialTheme.typography.titleMedium) - Text("screen=${snapshot?.screenOn} lock=${snapshot?.locked} appFg=${snapshot?.appInForeground}") - Text("service=${snapshot?.serviceRunning} mediaSession=${snapshot?.mediaSessionActive} logging=$isLogging") + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + when (selectedTab) { + 0 -> { + item { + PlaylistSection( + browserState = browserState, + onPickMp3 = onPickMp3, + onPickFolder = onPickFolder, + onUpFolder = onUpFolder + ) + } + if (browserState.currentDocumentId != null && browserState.entries.isEmpty()) { + item { + Text( + text = "No folders or MP3 files", + color = NowMutedText, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp) + ) + } + } + itemsIndexed(browserState.entries, key = { _, entry -> entry.documentId }) { index, entry -> + FolderEntryRow( + index = index + 1, + entry = entry, + enabled = !browserState.isLoadingPlaylist, + isCurrent = !entry.isDirectory && entry.name == playerState.title, + onOpenFolderEntry = onOpenFolderEntry, + onPlayFolderTrack = onPlayFolderTrack + ) + } + } + 1 -> { + item { + NowPlayingSection( + playerState = playerState, + onPlayPause = onPlayPause, + onSeekTo = onSeekTo, + onPrevious = onPrevious, + onNext = onNext + ) + } + } + 2 -> { + item { + SettingsSection( + snapshot = snapshot, + playerState = playerState, + keySeekStatus = keySeekStatus, + onStartLogging = onStartLogging, + onStopLogging = onStopLogging, + onClear = onClear, + onStartService = onStartService, + onStopService = onStopService, + onExport = onExport + ) + } + items(logs) { log -> + LogRow(log) + } + } + } + } + TabRow(selectedTabIndex = selectedTab) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(title) } + ) + } + } + } +} - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onStartLogging) { Text("로그 시작") } - Button(onClick = onStopLogging) { Text("로그 정지") } - Button(onClick = onClear) { Text("초기화") } +@Composable +private fun NowPlayingSection( + playerState: PlayerUiState, + onPlayPause: () -> Unit, + onSeekTo: (Long) -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit +) { + val duration = playerState.durationMs.coerceAtLeast(1L) + var sliderPosition by remember { mutableStateOf(playerState.positionMs.toFloat()) } + val artworkBitmap = remember(playerState.artworkUri) { + playerState.artworkUri + ?.let { Uri.parse(it).path } + ?.let { BitmapFactory.decodeFile(it) } + ?.asImageBitmap() + } + + LaunchedEffect(playerState.positionMs, playerState.durationMs) { + sliderPosition = playerState.positionMs.toFloat() + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = NowBackground, + shape = MaterialTheme.shapes.large + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("v", color = NowOrange, style = MaterialTheme.typography.titleMedium) + Text( + text = "NOW PLAYING", + color = NowOrange, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold + ) + Text(":", color = NowOrange, style = MaterialTheme.typography.titleMedium) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(MaterialTheme.shapes.large) + .background(NowArtGradient), + contentAlignment = Alignment.Center + ) { + if (artworkBitmap != null) { + Image( + bitmap = artworkBitmap, + contentDescription = "Album art", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Text( + text = coverText(playerState.title), + color = NowOrange, + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold + ) + } + } + + SubtitleBlock(playerState) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Bottom + ) { + Text( + modifier = Modifier.weight(1f), + text = playerState.title ?: "No track", + color = NowText, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "${playerState.currentIndex}/${playerState.queueSize}", + color = NowMutedText, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1 + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Slider( + modifier = Modifier.height(30.dp), + value = sliderPosition.coerceIn(0f, duration.toFloat()), + onValueChange = { sliderPosition = it }, + onValueChangeFinished = { onSeekTo(sliderPosition.toLong()) }, + valueRange = 0f..duration.toFloat(), + enabled = playerState.durationMs > 0L, + colors = SliderDefaults.colors( + thumbColor = NowOrange, + activeTrackColor = NowOrange, + inactiveTrackColor = NowRail + ) + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(formatMs(sliderPosition.toLong()), color = NowMutedText) + Text(formatMs(playerState.durationMs), color = NowMutedText) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(78.dp), + contentAlignment = Alignment.Center + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + TransportButton(label = "<<", onClick = onPrevious) + Spacer(Modifier.width(20.dp)) + Button( + modifier = Modifier.size(68.dp), + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + containerColor = NowOrange, + contentColor = Color(0xFF3A1D00) + ), + onClick = onPlayPause + ) { + Text( + text = if (playerState.wantsToPlay) "II" else ">", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + } + Spacer(Modifier.width(20.dp)) + TransportButton(label = ">>", onClick = onNext) + } + } } + } +} - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onStartService) { Text("서비스 시작") } - Button(onClick = onStopService) { Text("서비스 정지") } - Button(onClick = onExport) { Text("CSV 저장") } +@Composable +private fun SubtitleBlock(playerState: PlayerUiState) { + val text = when { + playerState.hasSubtitles -> playerState.subtitleText ?: "..." + else -> "No subtitles" + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + text = text, + color = if (playerState.hasSubtitles) NowText else NowMutedText, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) +} + +@Composable +private fun TransportButton(label: String, onClick: () -> Unit) { + Button( + modifier = Modifier.size(56.dp), + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = NowOrange + ), + contentPadding = PaddingValues(0.dp), + onClick = onClick + ) { + Text(label, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun PlaylistSection( + browserState: FolderBrowserState, + onPickMp3: () -> Unit, + onPickFolder: () -> Unit, + onUpFolder: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = NowBackground, + shape = MaterialTheme.shapes.large + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("v", color = NowOrange, style = MaterialTheme.typography.titleLarge) + Text( + text = "PLAYLIST", + color = PlaylistHeaderText, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text(":", color = PlaylistHeaderText, style = MaterialTheme.typography.titleLarge) + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + modifier = Modifier.weight(1f), + enabled = !browserState.isLoadingPlaylist, + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = NowOrange, + contentColor = Color(0xFF3A1D00) + ), + onClick = onPickFolder + ) { Text("Open folder", maxLines = 1) } + Button( + modifier = Modifier.weight(1f), + enabled = !browserState.isLoadingPlaylist, + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = PlaylistDimSurface, + contentColor = NowText + ), + border = BorderStroke(1.dp, NowRail), + onClick = onPickMp3 + ) { Text("Open MP3", maxLines = 1) } + } + + if (browserState.isLoadingPlaylist) { + Text("Loading playlist...", color = NowOrange) + } + + if (browserState.currentDocumentId == null) { + Text("Open a folder to browse", color = NowMutedText) + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + modifier = Modifier.weight(1f), + color = PlaylistDimSurface, + shape = MaterialTheme.shapes.large + ) { + Text( + text = browserState.path.joinToString(" / ") { it.name }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + color = NowMutedText, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + if (browserState.path.size > 1) { + Button( + enabled = !browserState.isLoadingPlaylist, + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = PlaylistDimSurface, + contentColor = NowText + ), + border = BorderStroke(1.dp, NowRail), + onClick = onUpFolder + ) { + Text("Up") + } + } + } + } + + PlaylistColumnHeader() + } + } +} + +@Composable +private fun PlaylistColumnHeader() { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("#", modifier = Modifier.width(48.dp), color = PlaylistHeaderText, fontWeight = FontWeight.Bold) + Text( + "TITLE", + modifier = Modifier.weight(1f), + color = PlaylistHeaderText, + fontWeight = FontWeight.Bold + ) + Text("--:--", color = PlaylistHeaderText, fontWeight = FontWeight.Bold) } + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color.White.copy(alpha = 0.08f)) + ) + } +} + +@Composable +private fun FolderEntryRow( + index: Int, + entry: FolderEntry, + enabled: Boolean, + isCurrent: Boolean, + onOpenFolderEntry: (FolderEntry) -> Unit, + onPlayFolderTrack: (FolderEntry) -> Unit +) { + val titleColor = when { + isCurrent -> NowOrange + else -> NowText + } + val metaColor = when { + isCurrent -> NowOrange.copy(alpha = 0.72f) + else -> PlaylistHeaderText + } + val marker = when { + entry.isDirectory -> "DIR" + isCurrent -> "|||" + else -> index.toString() + } + val subtitle = if (entry.isDirectory) "Folder" else "Audio track" + val trailing = if (entry.isDirectory) "Open" else entry.durationMs?.let { formatMs(it) } ?: "--:--" - Text("실시간 로그", style = MaterialTheme.typography.titleMedium) - LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) { - items(logs) { log -> - val actionName = if (log.action == KeyEvent.ACTION_DOWN) "DOWN" else "UP" + Button( + modifier = Modifier + .fillMaxWidth() + .height(82.dp), + enabled = enabled, + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = if (isCurrent) PlaylistActiveSurface else Color.Transparent, + contentColor = titleColor, + disabledContainerColor = Color.Transparent, + disabledContentColor = PlaylistHeaderText + ), + border = if (isCurrent) BorderStroke(1.dp, NowOrange.copy(alpha = 0.35f)) else null, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp), + onClick = { + if (entry.isDirectory) { + onOpenFolderEntry(entry) + } else { + onPlayFolderTrack(entry) + } + } + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = marker, + modifier = Modifier.width(48.dp), + color = metaColor, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - text = "${log.timestampMs} ${KeyEvent.keyCodeToString(log.keyCode)} $actionName repeat=${log.repeatCount} " + - "hold=${log.longPress} duration=${log.holdDurationMs ?: "-"} screen=${log.snapshot.screenOn} lock=${log.snapshot.locked} src=${log.source}", - style = MaterialTheme.typography.bodySmall + text = entry.name, + color = titleColor, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = subtitle, + color = metaColor, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } + Text( + text = trailing, + color = if (isCurrent) NowOrange else PlaylistHeaderText, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium, + maxLines = 1 + ) } } } + +@Composable +private fun SettingsSection( + snapshot: EnvironmentSnapshot?, + playerState: PlayerUiState, + keySeekStatus: String, + onStartLogging: () -> Unit, + onStopLogging: () -> Unit, + onClear: () -> Unit, + onStartService: () -> Unit, + onStopService: () -> Unit, + onExport: () -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Settings", style = MaterialTheme.typography.titleMedium) + Text("service=${playerState.serviceRunning} mediaSession=${snapshot?.mediaSessionActive}") + Text("screen=${snapshot?.screenOn} lock=${snapshot?.locked} appFg=${snapshot?.appInForeground}") + Text(keySeekStatus) + Text("Key behavior", style = MaterialTheme.typography.titleMedium) + Text("Screen on: short press skips, hold seeks") + Text("Screen off: short press skips, double-tap-hold seeks") + Text("Debug", style = MaterialTheme.typography.titleMedium) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(modifier = Modifier.weight(1f), onClick = onStartLogging) { Text("Start log") } + Button(modifier = Modifier.weight(1f), onClick = onStopLogging) { Text("Stop log") } + Button(modifier = Modifier.weight(1f), onClick = onClear) { Text("Clear") } + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(modifier = Modifier.weight(1f), onClick = onStartService) { Text("Start svc") } + Button(modifier = Modifier.weight(1f), onClick = onStopService) { Text("Stop svc") } + Button(modifier = Modifier.weight(1f), onClick = onExport) { Text("Save CSV") } + } + Text("Live log", style = MaterialTheme.typography.titleMedium) + } +} + +@Composable +private fun LogRow(log: KeyEventLogEntry) { + val actionName = if (log.action == KeyEvent.ACTION_DOWN) "DOWN" else "UP" + Text( + text = "${log.timestampMs} ${KeyEvent.keyCodeToString(log.keyCode)} $actionName repeat=${log.repeatCount} " + + "hold=${log.longPress} duration=${log.holdDurationMs ?: "-"} screen=${log.snapshot.screenOn} lock=${log.snapshot.locked} src=${log.source}", + style = MaterialTheme.typography.bodySmall + ) +} + +private fun coverText(title: String?): String { + val trimmed = title?.trim().orEmpty() + return trimmed.firstOrNull()?.uppercaseChar()?.toString() ?: "AMSR" +} + +private fun formatMs(ms: Long): String { + val safeMs = ms.coerceAtLeast(0L) + val totalSec = safeMs / 1000 + val min = totalSec / 60 + val sec = totalSec % 60 + return "%02d:%02d".format(min, sec) +} diff --git a/app/src/main/java/com/example/keyeventlogger/MediaButtonEventReceiver.kt b/app/src/main/java/com/example/keyeventlogger/MediaButtonEventReceiver.kt index b043bdd..56b086b 100644 --- a/app/src/main/java/com/example/keyeventlogger/MediaButtonEventReceiver.kt +++ b/app/src/main/java/com/example/keyeventlogger/MediaButtonEventReceiver.kt @@ -13,13 +13,12 @@ class MediaButtonEventReceiver : BroadcastReceiver() { val event = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as? KeyEvent ?: return if (!KeyLoggerService.isTargetKey(event.keyCode)) return + val snapshot = DeviceStateProvider.snapshot(context) KeyEventLogRepository.append( event = event, - snapshot = DeviceStateProvider.snapshot(context, serviceRunning = true), + snapshot = snapshot, source = "BroadcastReceiver" ) - - val serviceIntent = Intent(context, KeyLoggerService::class.java) - context.startForegroundService(serviceIntent) + KeyLoggerService.handleMediaKey(context, event, snapshot) } } diff --git a/app/src/main/java/com/example/keyeventlogger/PlayerModels.kt b/app/src/main/java/com/example/keyeventlogger/PlayerModels.kt new file mode 100644 index 0000000..73f3ba0 --- /dev/null +++ b/app/src/main/java/com/example/keyeventlogger/PlayerModels.kt @@ -0,0 +1,29 @@ +package com.example.keyeventlogger + +data class PlayerTrack( + val uri: String, + val title: String, + val artworkUri: String? = null, + val subtitles: List = emptyList() +) + +data class SubtitleCue( + val startMs: Long, + val endMs: Long, + val text: String +) + +data class PlayerUiState( + val serviceRunning: Boolean = false, + val isPlaying: Boolean = false, + val wantsToPlay: Boolean = false, + val title: String? = null, + val artworkUri: String? = null, + val subtitleText: String? = null, + val hasSubtitles: Boolean = false, + val currentIndex: Int = 0, + val queueSize: Int = 0, + val positionMs: Long = 0L, + val durationMs: Long = 0L, + val queue: List = emptyList() +) diff --git a/app/src/main/java/com/example/keyeventlogger/ScreenOffSeekController.kt b/app/src/main/java/com/example/keyeventlogger/ScreenOffSeekController.kt new file mode 100644 index 0000000..98c7c55 --- /dev/null +++ b/app/src/main/java/com/example/keyeventlogger/ScreenOffSeekController.kt @@ -0,0 +1,277 @@ +package com.example.keyeventlogger + +import android.view.KeyEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +object ScreenOffSeekController { + private const val SEEK_STEP_MS = 5_000L + private const val FIRST_SEEK_DELAY_MS = 350L + private const val SEEK_REPEAT_DELAY_MS = 500L + private const val DOUBLE_TAP_WINDOW_MS = 500L + private const val SCREEN_ON_HOLD_DELAY_MS = 450L + private const val DUPLICATE_DOWN_WINDOW_MS = 120L + private const val MAX_SEEK_HOLD_MS = 30_000L + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val _status = MutableStateFlow("keySeek=idle") + + val status: StateFlow = _status.asStateFlow() + + private var lastTapKeyCode: Int? = null + private var lastTapAtMs: Long = 0L + private var activeSeekKeyCode: Int? = null + private var activeSeekMode: ActiveSeekMode? = null + private var activeSeekStartedAtMs: Long = 0L + private var seekJob: Job? = null + private var pendingTapJob: Job? = null + private var pendingHoldJob: Job? = null + private var pendingHoldKeyCode: Int? = null + private var seekByHandler: ((Long) -> Unit)? = null + private var tapHandler: ((Int) -> Unit)? = null + private var lastDownSignature: DownSignature? = null + private var lastDownSeenAtMs: Long = 0L + + fun setSeekByHandler(handler: ((Long) -> Unit)?) { + seekByHandler = handler + if (handler == null) { + stopSeeking("keySeek=idle") + clearWaitingTap() + clearPendingHold() + } + } + + fun setTapHandler(handler: ((Int) -> Unit)?) { + tapHandler = handler + if (handler == null) { + clearWaitingTap() + clearPendingHold() + } + } + + fun handle(event: KeyEvent, snapshot: EnvironmentSnapshot): Boolean { + if (!isSeekKey(event.keyCode)) { + return false + } + + activeSeekMode?.let { mode -> + if (isRecentDuplicateDown(event)) { + return true + } + return handleActiveSeek(event, mode) + } + + if (isRecentDuplicateDown(event)) { + return true + } + + return if (snapshot.screenOn) { + handleScreenOn(event) + } else { + handleScreenOff(event) + } + } + + private fun handleScreenOn(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_UP && pendingHoldKeyCode == event.keyCode) { + clearPendingHold() + tapHandler?.invoke(event.keyCode) + _status.value = "keySeek=screenOnTap ${KeyEvent.keyCodeToString(event.keyCode)}" + return true + } + + if (event.action != KeyEvent.ACTION_DOWN) { + return false + } + + if (seekByHandler == null || tapHandler == null) { + return false + } + + if (pendingHoldKeyCode == event.keyCode) { + if (event.repeatCount > 0) { + clearPendingHold() + startSeeking( + keyCode = event.keyCode, + firstDelayMs = 0L, + mode = ActiveSeekMode.ScreenOnHold + ) + } + return true + } + + clearPendingHold() + pendingHoldKeyCode = event.keyCode + pendingHoldJob = scope.launch { + delay(SCREEN_ON_HOLD_DELAY_MS) + if (pendingHoldKeyCode == event.keyCode && activeSeekKeyCode == null) { + clearPendingHold() + startSeeking( + keyCode = event.keyCode, + firstDelayMs = 0L, + mode = ActiveSeekMode.ScreenOnHold + ) + } + } + _status.value = "keySeek=screenOnWaitingHold ${KeyEvent.keyCodeToString(event.keyCode)}" + return true + } + + private fun handleScreenOff(event: KeyEvent): Boolean { + if (event.action != KeyEvent.ACTION_DOWN) { + return false + } + + val now = System.currentTimeMillis() + val isSecondTap = + lastTapKeyCode == event.keyCode && + now - lastTapAtMs <= DOUBLE_TAP_WINDOW_MS && + seekByHandler != null + + if (isSecondTap) { + clearWaitingTap() + startSeeking(event.keyCode, mode = ActiveSeekMode.ScreenOffDoubleTap) + return true + } + + if (tapHandler == null) { + return false + } + + lastTapKeyCode = event.keyCode + lastTapAtMs = now + pendingTapJob?.cancel() + pendingTapJob = scope.launch { + delay(DOUBLE_TAP_WINDOW_MS) + if (lastTapKeyCode == event.keyCode && activeSeekKeyCode == null) { + tapHandler?.invoke(event.keyCode) + clearWaitingTap() + _status.value = "keySeek=singleTap ${KeyEvent.keyCodeToString(event.keyCode)}" + } + } + _status.value = "keySeek=screenOffWaitingSecondTap ${KeyEvent.keyCodeToString(event.keyCode)}" + return true + } + + private fun handleActiveSeek(event: KeyEvent, mode: ActiveSeekMode): Boolean { + val activeKey = activeSeekKeyCode + if (event.keyCode != activeKey) { + stopSeeking("keySeek=cancelled") + return false + } + + return when (mode) { + ActiveSeekMode.ScreenOnHold -> { + when (event.action) { + KeyEvent.ACTION_UP -> { + stopSeeking("keySeek=ended ${KeyEvent.keyCodeToString(event.keyCode)}") + true + } + KeyEvent.ACTION_DOWN -> true + else -> false + } + } + ActiveSeekMode.ScreenOffDoubleTap -> { + if (event.action == KeyEvent.ACTION_DOWN || event.action == KeyEvent.ACTION_UP) { + stopSeeking("keySeek=ended ${KeyEvent.keyCodeToString(event.keyCode)}") + true + } else { + false + } + } + } + } + + private fun startSeeking( + keyCode: Int, + firstDelayMs: Long = FIRST_SEEK_DELAY_MS, + mode: ActiveSeekMode + ) { + val handler = seekByHandler ?: return + val direction = when (keyCode) { + KeyEvent.KEYCODE_MEDIA_NEXT -> 1L + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> -1L + else -> return + } + val keyName = KeyEvent.keyCodeToString(keyCode) + + activeSeekKeyCode = keyCode + activeSeekMode = mode + activeSeekStartedAtMs = System.currentTimeMillis() + _status.value = "keySeek=started $keyName" + seekJob = scope.launch { + delay(firstDelayMs) + while (activeSeekKeyCode == keyCode) { + val elapsedMs = System.currentTimeMillis() - activeSeekStartedAtMs + if (elapsedMs > MAX_SEEK_HOLD_MS) { + stopSeeking("keySeek=timeout $keyName") + break + } + handler(direction * SEEK_STEP_MS) + _status.value = "keySeek=seeking $keyName elapsed=${elapsedMs}ms" + delay(SEEK_REPEAT_DELAY_MS) + } + } + } + + private fun stopSeeking(status: String) { + seekJob?.cancel() + seekJob = null + activeSeekKeyCode = null + activeSeekMode = null + activeSeekStartedAtMs = 0L + _status.value = status + } + + private fun clearWaitingTap() { + pendingTapJob?.cancel() + pendingTapJob = null + lastTapKeyCode = null + lastTapAtMs = 0L + } + + private fun clearPendingHold() { + pendingHoldJob?.cancel() + pendingHoldJob = null + pendingHoldKeyCode = null + } + + private fun isRecentDuplicateDown(event: KeyEvent): Boolean { + if (event.action != KeyEvent.ACTION_DOWN) { + return false + } + val now = System.currentTimeMillis() + val signature = DownSignature( + keyCode = event.keyCode, + downTime = event.downTime, + repeatCount = event.repeatCount + ) + if (signature == lastDownSignature && now - lastDownSeenAtMs <= DUPLICATE_DOWN_WINDOW_MS) { + return true + } + lastDownSignature = signature + lastDownSeenAtMs = now + return false + } + + private fun isSeekKey(keyCode: Int): Boolean = + keyCode == KeyEvent.KEYCODE_MEDIA_NEXT || keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS + + private data class DownSignature( + val keyCode: Int, + val downTime: Long, + val repeatCount: Int + ) + + private enum class ActiveSeekMode { + ScreenOnHold, + ScreenOffDoubleTap + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4405d33..16f64db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ - JM21 Key Logger + AMSR Player diff --git a/build.gradle.kts b/build.gradle.kts index 017d909..d5e9eb5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,4 @@ plugins { - id("com.android.application") version "8.5.2" apply false + id("com.android.application") version "8.6.0" apply false id("org.jetbrains.kotlin.android") version "1.9.24" apply false } diff --git a/docs/TEST_PROCEDURE.md b/docs/TEST_PROCEDURE.md index 1d4dcb9..4473692 100644 --- a/docs/TEST_PROCEDURE.md +++ b/docs/TEST_PROCEDURE.md @@ -73,6 +73,10 @@ Android Studio를 설치한 직후라면 아래 순서로 진행하세요. 권장 확인 명령: - `adb shell dumpsys activity services | grep -i KeyLoggerService` +MP3 탐색 테스트를 하려면: +- **MP3 열기** 버튼으로 파일 선택 +- **재생/일시정지**, **-5초 / +5초** 버튼으로 기본 탐색 동작 확인 + ## 3) 핵심 테스트 시나리오 아래 각 시나리오마다 **볼륨업/볼륨다운/이전곡/재생일시정지/다음곡**을 각각 테스트합니다. @@ -152,6 +156,10 @@ Android Studio를 설치한 직후라면 아래 순서로 진행하세요. - **Gradle Sync 실패** - Settings > Build Tools > Gradle에서 JDK를 Embedded JDK(17)로 설정 - 네트워크 이슈 시 재시도 + - `Minimum supported Gradle version is 8.6/8.7` 오류가 뜨면, + - 프로젝트 `build.gradle.kts`의 AGP 버전과 Gradle 버전이 맞지 않는 상황 + - Android Studio에서 Gradle Wrapper를 8.7+로 올리거나(권장), + - 또는 AGP 버전을 8.2.x로 낮춰 호환 맞추기 - **앱 설치 실패(INSTALL_FAILED_...)** - 기존 동일 패키지 앱 삭제 후 재설치 - **로그가 안 찍힘** diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b82aa23 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega