Initial Import

Signed-off-by: kenway214 <kenway214@outlook.com>
This commit is contained in:
kenway214
2025-10-23 18:40:25 +05:30
commit aca1b4d4ed
100 changed files with 10669 additions and 0 deletions

38
Android.bp Normal file
View File

@@ -0,0 +1,38 @@
//
// Copyright (C) 2025 kenway214
// SPDX-License-Identifier: Apache-2.0
//
android_app {
name: "GameBar",
defaults: [
"SettingsLibDefaults",
],
srcs: [
"src/**/*.kt",
"src/**/*.java"
],
certificate: "platform",
resource_dirs: ["res"],
platform_apis: true,
system_ext_specific: true,
privileged: true,
static_libs: [
"androidx.core_core",
"org.lineageos.settings.resources"
],
required: [
"privapp_whitelist_com.android.gamebar.xml",
],
}
prebuilt_etc {
name: "privapp_whitelist_com.android.gamebar.xml",
src: "permissions/privapp_whitelist_com.android.gamebar.xml",
sub_dir: "permissions",
system_ext_specific: true,
}

149
AndroidManifest.xml Normal file
View File

@@ -0,0 +1,149 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.android.gamebar"
android:versionCode="1"
android:versionName="1.0"
android:sharedUserId="android.uid.system">
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.GET_TASKS" />
<uses-permission android:name="android.permission.ACCESS_SURFACE_FLINGER" />
<uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS"/>
<uses-permission android:name="android.permission.INTERACT_ACROSS_USERS_FULL" />
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.SYSTEM_APPLICATION_OVERLAY" />
<uses-permission android:name="android.permission.MANAGE_GAME_MODE" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.READ_DEVICE_CONFIG" />
<uses-permission android:name="android.permission.ACCESS_FPS_COUNTER" />
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<uses-sdk
android:minSdkVersion="33"
android:targetSdkVersion="35"/>
<application
android:label="@string/app_title"
android:persistent="true"
android:defaultToDeviceProtectedStorage="true"
android:directBootAware="true"
android:theme="@style/Theme.SubSettingsBase">
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:replace="android:authorities"/>
<!-- TileHandler activity -->
<activity
android:name=".utils.TileHandlerActivity"
android:exported="true"
android:noHistory="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity>
<!-- GameBar Overlay -->
<activity
android:name=".GameBarSettingsActivity"
android:label="@string/game_bar_title"
android:theme="@style/Theme.SubSettingsBase"
android:exported="true">
<intent-filter>
<action android:name="com.android.settings.action.IA_SETTINGS" />
</intent-filter>
<meta-data
android:name="com.android.settings.category"
android:value="com.android.settings.category.ia.apps" />
<meta-data
android:name="com.android.settings.icon"
android:resource="@drawable/ic_gamebar" />
</activity>
<!-- GameBar Overlay Tile Service -->
<service
android:name=".GameBarTileService"
android:label="@string/game_bar_tile_label"
android:icon="@drawable/ic_gamebar"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" />
</service>
<!-- GameBar Overlay Monitor Service -->
<service
android:name=".GameBarMonitorService"
android:exported="false" />
<!-- Per-App Logging Service -->
<service
android:name=".PerAppLogService"
android:exported="false" />
<!-- GameBar BootReceiver -->
<receiver
android:name=".GameBarBootReceiver"
android:exported="true"
android:enabled="true"
android:permission="android.permission.RECEIVE_BOOT_COMPLETED">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</receiver>
<activity
android:name=".GameBarPerAppConfigActivity"
android:exported="false"
android:theme="@style/Theme.SubSettingsBase" />
<activity
android:name=".GameBarLogActivity"
android:exported="false"
android:theme="@style/Theme.SubSettingsBase" />
<activity
android:name=".PerAppLogViewActivity"
android:exported="false"
android:theme="@style/Theme.SubSettingsBase" />
<activity
android:name=".LogAnalyticsActivity"
android:exported="false"
android:theme="@style/Theme.SubSettingsBase" />
<!-- FileProvider for sharing log files -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
</application>
</manifest>

1
README.md Normal file
View File

@@ -0,0 +1 @@
Initial Release of GameBar

15
gamebar.mk Normal file
View File

@@ -0,0 +1,15 @@
#
# Copyright (C) 2025 kenway214
# SPDX-License-Identifier: Apache-2.0
#
# GameBar app
PRODUCT_PACKAGES += \
GameBar
# GameBar init rc
PRODUCT_PACKAGES += \
init.gamebar.rc
# Gamebar sepolicy
include packages/apps/GameBar/sepolicy/SEPolicy.mk

11
init/Android.bp Normal file
View File

@@ -0,0 +1,11 @@
//
// Copyright (C) 2025 kenway214
// SPDX-License-Identifier: Apache-2.0
//
prebuilt_etc {
name: "init.gamebar.rc",
src: "init.gamebar.rc",
sub_dir: "init",
vendor: true,
}

6
init/init.gamebar.rc Normal file
View File

@@ -0,0 +1,6 @@
on boot
chown system graphics /sys/class/drm/sde-crtc-0/measured_fps
chmod 0660 /sys/class/drm/sde-crtc-0/measured_fps
chown system system /sys/class/power_supply/battery/temp
chmod 0660 /sys/class/power_supply/battery/temp

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<permissions>
<privapp-permissions package="com.android.gamebar">
<permission name="android.permission.WRITE_SECURE_SETTINGS"/>
<permission name="android.permission.GET_TASKS" />
<permission name="android.permission.INTERACT_ACROSS_USERS" />
<permission name="android.permission.MANAGE_GAME_MODE" />
<permission name="android.permission.QUERY_ALL_PACKAGES" />
<permission name="android.permission.READ_DEVICE_CONFIG" />
<permission name="android.permission.RECEIVE_BOOT_COMPLETED" />
<permission name="android.permission.SYSTEM_ALERT_WINDOW" />
<permission name="android.permission.WRITE_DEVICE_CONFIG" />
<permission name="android.permission.WAKE_LOCK" />
<permission name="android.permission.ACCESS_FPS_COUNTER" />
</privapp-permissions>
</permissions>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#FFFFFF"/>
</selector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#B0B0B0"/>
</selector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#232B36"/>
</selector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#FFFFFF"/>
</selector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#232B36"/>
</selector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#000000"/>
</selector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#444444"/>
</selector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#E3EAF5"/>
</selector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#232B36"/>
</selector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#4CAF50"/>
</selector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#F0F0F0"/>
</selector>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/button_bg_selector"/>
<corners android:radius="24dp"/>
</shape>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#E0E0E0"/>
<corners android:radius="24dp"/>
<size android:width="48dp" android:height="48dp"/>
<padding android:left="2dp" android:top="2dp" android:right="2dp" android:bottom="2dp"/>
</shape>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/search_bg_selector"/>
<corners android:radius="16dp"/>
<padding android:left="8dp" android:top="4dp" android:right="8dp" android:bottom="4dp"/>
</shape>

View File

@@ -0,0 +1,14 @@
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M400,680L560,680L560,600L400,600L400,680ZM400,520L680,520L680,440L400,440L400,520ZM280,360L680,360L680,280L280,280L280,360ZM480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480ZM265,880Q186,880 130.5,824.5Q75,769 75,690Q75,633 104.5,588Q134,543 182,520L80,520L80,440L320,440L320,680L240,680L240,583Q203,591 179,621Q155,651 155,690Q155,736 187.5,768Q220,800 265,800L265,880ZM400,840L400,760L760,760Q760,760 760,760Q760,760 760,760L760,200Q760,200 760,200Q760,200 760,200L200,200Q200,200 200,200Q200,200 200,200L200,360L120,360L120,200Q120,167 143.5,143.5Q167,120 200,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,760Q840,793 816.5,816.5Q793,840 760,840L400,840Z"/>
</vector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M8.59,16.59L13.17,12 8.59,7.41 10,6l6,6 -6,6z"/>
</vector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2018-2022 crDroid Android Project
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24.0dp"
android:height="24.0dp"
android:viewportWidth="48.0"
android:viewportHeight="48.0" >
<path
android:fillColor="?android:attr/colorControlNormal"
android:pathData="M24 4C12.972 4 4 12.972 4 24s8.972 20 20 20s20 -8.972 20 -20S35.028 4 24 4zM32.5 25.5h-17c-0.829 0 -1.5 -0.671 -1.5 -1.5s0.671 -1.5 1.5 -1.5h17c0.829 0 1.5 0.671 1.5 1.5S33.329 25.5 32.5 25.5z" />
</vector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2018-2022 crDroid Android Project
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24.0dp"
android:height="24.0dp"
android:viewportWidth="48.0"
android:viewportHeight="48.0" >
<path
android:fillColor="?android:attr/colorControlNormal"
android:pathData="M24 4C12.972 4 4 12.972 4 24s8.972 20 20 20s20 -8.972 20 -20S35.028 4 24 4zM32.5 25.5h-7v7c0 0.829 -0.672 1.5 -1.5 1.5s-1.5 -0.671 -1.5 -1.5v-7h-7c-0.828 0 -1.5 -0.671 -1.5 -1.5s0.672 -1.5 1.5 -1.5h7v-7c0 -0.829 0.672 -1.5 1.5 -1.5s1.5 0.671 1.5 1.5v7h7c0.828 0 1.5 0.671 1.5 1.5S33.328 25.5 32.5 25.5z" />
</vector>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2019 Havoc-OS
2022 DerpFest
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24.0dp"
android:height="24.0dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0" >
<path
android:fillColor="?android:attr/colorControlNormal"
android:pathData="M14.123456746339798,12.042328000068665 c0,-0.7430335164070133 -0.6079365134239197,-1.3509700298309326 -1.3509700298309326,-1.3509700298309326 s-1.3509700298309326,0.6079365134239197 -1.3509700298309326,1.3509700298309326 s0.6079365134239197,1.3509700298309326 1.3509700298309326,1.3509700298309326 s1.3509700298309326,-0.6079365134239197 1.3509700298309326,-1.3509700298309326 zM12.772486716508865,5.962962865829468 c-3.3571605241298674,0 -6.079365134239197,2.7222046101093293 -6.079365134239197,6.079365134239197 L4.66666653752327,12.042328000068665 l2.7019400596618652,2.7019400596618652 l2.7019400596618652,-2.7019400596618652 L8.044091612100601,12.042328000068665 c0,-2.6141270077228547 2.1142680966854095,-4.728395104408264 4.728395104408264,-4.728395104408264 s4.728395104408264,2.1142680966854095 4.728395104408264,4.728395104408264 s-2.1142680966854095,4.728395104408264 -4.728395104408264,4.728395104408264 c-1.0199823725223542,0 -1.965661393404007,-0.3309876573085789 -2.742469160556793,-0.8781305193901066 l-0.9591887211799618,0.9726984214782719 C10.097566057443618,17.648853623867033 11.380987585783004,18.12169313430786 12.772486716508865,18.12169313430786 c3.3571605241298674,0 6.079365134239197,-2.7222046101093293 6.079365134239197,-6.079365134239197 s-2.7222046101093293,-6.079365134239197 -6.079365134239197,-6.079365134239197 z" />
</vector>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
</vector>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M9,3L5,6.99h3V14h2V6.99h3L9,3zM16,17.01V10h-2v7.01h-3L15,21l4,-3.99H16z"/>
</vector>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24.0dp"
android:height="24.0dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:tint="?android:attr/colorControlNormal">
<path
android:fillColor="#FF000000"
android:pathData="M7,6H17A6,6 0 0,1 23,12A6,6 0 0,1 17,18C15.22,18 13.63,17.23 12.53,16H11.47C10.37,17.23 8.78,18 7,18A6,6 0 0,1 1,12A6,6 0 0,1 7,6M6,9V11H4V13H6V15H8V13H10V11H8V9H6M15.5,12A1.5,1.5 0 0,0 14,13.5A1.5,1.5 0 0,0 15.5,15A1.5,1.5 0 0,0 17,13.5A1.5,1.5 0 0,0 15.5,12M18.5,9A1.5,1.5 0 0,0 17,10.5A1.5,1.5 0 0,0 18.5,12A1.5,1.5 0 0,0 20,10.5A1.5,1.5 0 0,0 18.5,9Z" />
</vector>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M19,19H5V5h7V3H5c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2v7zM14,3v2h3.59l-9.83,9.83 1.41,1.41L19,6.41V10h2V3h-7z"/>
</vector>

15
res/drawable/ic_share.xml Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z"/>
</vector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?android:attr/colorBackground" />
<corners android:radius="8dp" />
<stroke
android:width="1dp"
android:color="?android:attr/colorControlHighlight" />
</shape>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment
android:id="@+id/game_bar_fragment"
android:name="com.android.gamebar.GameBarFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/content_frame"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/content_frame"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#121212" />

View File

@@ -0,0 +1,603 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fillViewport="true"
android:background="#121212">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- Session Info Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:backgroundTint="#121212"
android:elevation="4dp">
<TextView
android:id="@+id/tv_session_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Session Information"
android:textColor="@android:color/white"
android:textSize="13sp"
android:padding="12dp"
android:fontFamily="monospace"
android:lineSpacingExtra="2dp" />
</androidx.cardview.widget.CardView>
<!-- FPS Graph Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:backgroundTint="#121212"
android:elevation="4dp">
<com.android.gamebar.FpsGraphView
android:id="@+id/fps_graph_view"
android:layout_width="match_parent"
android:layout_height="400dp"
android:padding="8dp" />
</androidx.cardview.widget.CardView>
<!-- Frame Time Graph Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:backgroundTint="#121212"
android:elevation="4dp">
<com.android.gamebar.FrameTimeGraphView
android:id="@+id/frame_time_graph_view"
android:layout_width="match_parent"
android:layout_height="400dp"
android:padding="8dp" />
</androidx.cardview.widget.CardView>
<!-- Divider -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#333333"
android:layout_marginVertical="8dp" />
<!-- FPS Statistics -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="FPS STATISTICS"
android:textColor="@android:color/white"
android:textSize="16sp"
android:textStyle="bold"
android:paddingVertical="8dp"
android:gravity="center" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="2">
<!-- Left Column -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingEnd="8dp">
<TextView
android:id="@+id/tv_max_fps"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Max FPS: 0.0"
android:textColor="#4CAF50"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
<TextView
android:id="@+id/tv_min_fps"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Min FPS: 0.0"
android:textColor="#F44336"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
<TextView
android:id="@+id/tv_avg_fps"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Avg FPS: 0.0"
android:textColor="#FF9800"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
</LinearLayout>
<!-- Right Column -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingStart="8dp">
<TextView
android:id="@+id/tv_variance"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Variance: 0.00"
android:textColor="@android:color/white"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
<TextView
android:id="@+id/tv_std_dev"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Std Dev: 0.00"
android:textColor="@android:color/white"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
<TextView
android:id="@+id/tv_smoothness"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Smoothness: 0.0%"
android:textColor="#4CAF50"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
</LinearLayout>
</LinearLayout>
<!-- Frame Time Lows (under FPS Statistics) -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#333333"
android:layout_marginVertical="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="2">
<TextView
android:id="@+id/tv_1percent_low"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="1% Low: 0.0 FPS"
android:textColor="#F44336"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp"
android:paddingEnd="8dp" />
<TextView
android:id="@+id/tv_01percent_low"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="0.1% Low: 0.0 FPS"
android:textColor="#FF5252"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp"
android:paddingStart="8dp" />
</LinearLayout>
<!-- Divider -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#333333"
android:layout_marginVertical="16dp" />
<!-- CPU Statistics Section -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="CPU STATISTICS"
android:textColor="@android:color/white"
android:textSize="16sp"
android:textStyle="bold"
android:paddingVertical="8dp"
android:gravity="center" />
<!-- CPU Usage Graph Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:backgroundTint="#121212"
android:elevation="4dp">
<com.android.gamebar.CpuGraphView
android:id="@+id/cpu_usage_graph_view"
android:layout_width="match_parent"
android:layout_height="250dp"
android:padding="8dp" />
</androidx.cardview.widget.CardView>
<!-- CPU Temperature Graph Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:backgroundTint="#121212"
android:elevation="4dp">
<com.android.gamebar.CpuTempGraphView
android:id="@+id/cpu_temp_graph_view"
android:layout_width="match_parent"
android:layout_height="250dp"
android:padding="8dp" />
</androidx.cardview.widget.CardView>
<!-- CPU Clock Graph Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:backgroundTint="#121212"
android:elevation="4dp">
<com.android.gamebar.CpuClockGraphView
android:id="@+id/cpu_clock_graph_view"
android:layout_width="match_parent"
android:layout_height="300dp"
android:padding="8dp" />
</androidx.cardview.widget.CardView>
<!-- Divider -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#333333"
android:layout_marginVertical="8dp" />
<!-- CPU Stats Summary -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="CPU SUMMARY"
android:textColor="@android:color/white"
android:textSize="16sp"
android:textStyle="bold"
android:paddingVertical="8dp"
android:gravity="center" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="2">
<!-- Left Column -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingEnd="8dp">
<TextView
android:id="@+id/tv_max_cpu_usage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Max Usage: 0%"
android:textColor="#4CAF50"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
<TextView
android:id="@+id/tv_min_cpu_usage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Min Usage: 0%"
android:textColor="#F44336"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
<TextView
android:id="@+id/tv_avg_cpu_usage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Avg Usage: 0%"
android:textColor="#FF9800"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
</LinearLayout>
<!-- Right Column -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingStart="8dp">
<TextView
android:id="@+id/tv_max_cpu_temp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Max Temp: 0.0°C"
android:textColor="#FF5722"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
<TextView
android:id="@+id/tv_min_cpu_temp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Min Temp: 0.0°C"
android:textColor="#4CAF50"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
<TextView
android:id="@+id/tv_avg_cpu_temp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Avg Temp: 0.0°C"
android:textColor="#FF9800"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
</LinearLayout>
</LinearLayout>
<!-- Divider -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#333333"
android:layout_marginVertical="16dp" />
<!-- GPU Statistics Section -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="GPU STATISTICS"
android:textColor="@android:color/white"
android:textSize="16sp"
android:textStyle="bold"
android:paddingVertical="8dp"
android:gravity="center" />
<!-- GPU Usage Graph Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:backgroundTint="#121212"
android:elevation="4dp">
<com.android.gamebar.GpuUsageGraphView
android:id="@+id/gpu_usage_graph_view"
android:layout_width="match_parent"
android:layout_height="250dp"
android:padding="8dp" />
</androidx.cardview.widget.CardView>
<!-- GPU Temperature Graph Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:backgroundTint="#121212"
android:elevation="4dp">
<com.android.gamebar.GpuTempGraphView
android:id="@+id/gpu_temp_graph_view"
android:layout_width="match_parent"
android:layout_height="250dp"
android:padding="8dp" />
</androidx.cardview.widget.CardView>
<!-- GPU Clock Graph Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:backgroundTint="#121212"
android:elevation="4dp">
<com.android.gamebar.GpuClockGraphView
android:id="@+id/gpu_clock_graph_view"
android:layout_width="match_parent"
android:layout_height="250dp"
android:padding="8dp" />
</androidx.cardview.widget.CardView>
<!-- Divider -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#333333"
android:layout_marginVertical="8dp" />
<!-- GPU Stats Summary -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="GPU SUMMARY"
android:textColor="@android:color/white"
android:textSize="16sp"
android:textStyle="bold"
android:paddingVertical="8dp"
android:gravity="center" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="2">
<!-- Left Column -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingEnd="8dp">
<TextView
android:id="@+id/tv_max_gpu_usage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Max Usage: 0%"
android:textColor="#4CAF50"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
<TextView
android:id="@+id/tv_min_gpu_usage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Min Usage: 0%"
android:textColor="#F44336"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
<TextView
android:id="@+id/tv_avg_gpu_usage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Avg Usage: 0%"
android:textColor="#FF9800"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
</LinearLayout>
<!-- Right Column -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingStart="8dp">
<TextView
android:id="@+id/tv_max_gpu_clock"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Max Clock: 0 MHz"
android:textColor="#4CAF50"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
<TextView
android:id="@+id/tv_min_gpu_clock"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Min Clock: 0 MHz"
android:textColor="#F44336"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
<TextView
android:id="@+id/tv_avg_gpu_clock"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Avg Clock: 0 MHz"
android:textColor="#FF9800"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
</LinearLayout>
</LinearLayout>
<!-- GPU Temperature Stats -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#333333"
android:layout_marginVertical="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="3">
<TextView
android:id="@+id/tv_max_gpu_temp"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Max Temp: 0.0°C"
android:textColor="#FF5722"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp"
android:paddingEnd="8dp" />
<TextView
android:id="@+id/tv_min_gpu_temp"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Min Temp: 0.0°C"
android:textColor="#4CAF50"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp" />
<TextView
android:id="@+id/tv_avg_gpu_temp"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Avg Temp: 0.0°C"
android:textColor="#FF9800"
android:textSize="14sp"
android:fontFamily="monospace"
android:paddingVertical="4dp"
android:paddingStart="8dp" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="8dp">
<EditText
android:id="@+id/search_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Search logs..."
android:inputType="text"
android:background="@drawable/bg_search_rounded"
android:padding="12dp"
android:textColor="@android:color/primary_text_light"
android:textColorHint="@android:color/darker_gray"
android:elevation="2dp"
android:layout_marginBottom="8dp"/>
<RadioGroup
android:id="@+id/rg_log_type"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp"
android:gravity="center">
<RadioButton
android:id="@+id/rb_global_logging"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Global"
android:textColor="@color/app_name_text_selector"
android:checked="true"/>
<RadioButton
android:id="@+id/rb_per_app_logging"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Per App"
android:textColor="@color/app_name_text_selector"/>
</RadioGroup>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp">
<Button
android:id="@+id/btn_manual_logs"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Manual Logs"
android:background="@drawable/bg_button_rounded"
android:textColor="@color/button_text_selector"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:elevation="2dp"
android:visibility="gone"/>
<Space
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1"/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/btn_start_capture"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Start Capture"
android:background="@drawable/bg_button_rounded"
android:textColor="@color/button_text_selector"
android:paddingLeft="24dp"
android:paddingRight="24dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:elevation="2dp"/>
<Button
android:id="@+id/btn_stop_capture"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Stop Capture"
android:background="@drawable/bg_button_rounded"
android:textColor="@color/button_text_selector"
android:paddingLeft="24dp"
android:paddingRight="24dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:layout_marginStart="8dp"
android:elevation="2dp"/>
</LinearLayout>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_log_history"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
</LinearLayout>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="8dp">
<EditText
android:id="@+id/search_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Search logs..."
android:inputType="text"
android:background="@drawable/bg_search_rounded"
android:padding="12dp"
android:textColor="@android:color/primary_text_light"
android:textColorHint="@android:color/darker_gray"
android:elevation="2dp"
android:layout_marginBottom="8dp"/>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_log_history"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<LinearLayout
android:id="@+id/empty_state"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_marginBottom="16dp"
android:alpha="0.5"
android:src="@drawable/ic_article_shortcut"
android:tint="@android:color/darker_gray" />
<TextView
android:id="@+id/tv_empty_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="No logs available"
android:textColor="@android:color/darker_gray"
android:textSize="16sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="Enable per-app logging and use the app to generate logs"
android:textColor="@android:color/darker_gray"
android:textSize="14sp" />
</LinearLayout>
</FrameLayout>
</LinearLayout>

14
res/layout/game_bar.xml Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/game_bar_root"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#80000000"
android:padding="8dp"
android:orientation="vertical">
</LinearLayout>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true">
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="12dp"
android:src="@drawable/ic_export"
android:scaleType="centerInside"
android:background="@drawable/bg_round_icon"/>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_file_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold"
android:textSize="16sp"
android:textColor="@color/app_name_text_selector"
android:maxLines="1"
android:ellipsize="end"/>
<TextView
android:id="@+id/tv_file_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@color/app_package_text_selector"
android:maxLines="1"
android:ellipsize="end"/>
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical"
android:background="?android:attr/selectableItemBackground">
<ImageView
android:id="@+id/iv_app_icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="12dp"
android:scaleType="centerInside" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_app_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold"
android:textSize="16sp"
android:textColor="@color/app_name_text_selector"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/tv_package_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@color/app_package_text_selector"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>
<ImageView
android:id="@+id/iv_view_logs"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_article_shortcut"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:padding="4dp"
android:scaleType="centerInside" />
<Switch
android:id="@+id/switch_per_app_logging"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp" />
</LinearLayout>

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="16dp"
android:paddingBottom="8dp"
android:background="?android:attr/selectableItemBackground">
<ImageView
android:id="@+id/expand_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="12dp"
android:src="@drawable/ic_chevron_right"
android:tint="?android:attr/textColorSecondary"
android:rotation="0"
android:contentDescription="Expand/Collapse" />
<TextView
android:id="@android:id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="?android:attr/textColorPrimary"
android:textStyle="bold"
android:textSize="14sp"
android:singleLine="true"
android:ellipsize="end" />
</LinearLayout>

View File

@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2017-2022 crDroid Android Project
SPDX-License-Identifier: Apache-2.0
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?android:attr/listPreferredItemHeightSmall"
android:gravity="center_vertical"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:background="?android:attr/activatedBackgroundIndicator"
android:clipToPadding="false">
<LinearLayout
android:id="@android:id/icon_frame"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="-4dp"
android:minWidth="60dp"
android:gravity="start|center_vertical"
android:orientation="horizontal"
android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<com.android.internal.widget.PreferenceImageView
android:id="@android:id/icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="48dp"
android:maxHeight="48dp" />
</LinearLayout>
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingTop="16dp"
android:paddingBottom="16dp">
<TextView
android:id="@android:id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceListItem"
android:ellipsize="marquee" />
<TextView
android:id="@android:id/summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@android:id/title"
android:layout_alignStart="@android:id/title"
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
android:textColor="?android:attr/textColorSecondary"
android:maxLines="10"
android:ellipsize="end" />
<RelativeLayout
android:id="@+id/value_frame"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@android:id/summary"
android:layout_alignStart="@android:id/title" >
<TextView
android:id="@+id/value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
android:textColor="?android:attr/textColorSecondary"
android:maxLines="1"
android:ellipsize="end" />
<ImageView
android:id="@+id/reset"
android:src="@drawable/ic_custom_seekbar_reset"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_toEndOf="@id/value"
android:layout_centerVertical="true" />
</RelativeLayout>
<RelativeLayout
android:id="@+id/seekbar_frame"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/value_frame"
android:layout_alignStart="@android:id/title" >
<ImageView
android:id="@+id/minus"
android:src="@drawable/ic_custom_seekbar_minus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true" />
<ImageView
android:id="@+id/plus"
android:src="@drawable/ic_custom_seekbar_plus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true" />
<LinearLayout
android:id="@+id/seekbar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_toEndOf="@id/minus"
android:layout_toStartOf="@id/plus"
android:layout_centerVertical="true" />
</RelativeLayout>
</RelativeLayout>
<!-- Preference should place its actual preference widget here. -->
<LinearLayout android:id="@android:id/widget_frame"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="end|center_vertical"
android:paddingStart="16dp"
android:orientation="vertical" />
</LinearLayout>

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2015 The Android Open Source Project
(C) 2018-2020 The LineageOS Project
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
http://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.
-->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginBottom="6dp"
android:gravity="center_vertical"
android:minHeight="?android:attr/listPreferredItemHeight"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd">
<FrameLayout
android:id="@android:id/icon_frame"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@android:id/summary">
<ImageView
android:id="@android:id/icon"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginEnd="12dp"
android:layout_gravity="start|center_vertical" />
</FrameLayout>
<TextView
android:id="@android:id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_toEndOf="@android:id/icon_frame"
android:ellipsize="marquee"
android:fadingEdge="horizontal"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceListItem"
android:textColor="?android:attr/textColorPrimary" />
<TextView
android:id="@android:id/summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@android:id/title"
android:layout_toEndOf="@android:id/icon_frame"
android:maxLines="4"
android:paddingTop="8dp"
android:paddingBottom="5dp"
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
android:textColor="?android:attr/textColorSecondary" />
<SeekBar
android:id="@+id/seekbar"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_below="@android:id/summary"
android:layout_gravity="center_vertical"
android:layout_toEndOf="@android:id/icon_frame"
android:tickMark="@null"
android:paddingStart="12dp"
android:paddingEnd="12dp"
style="@android:style/Widget.Material.SeekBar.Discrete" />
</RelativeLayout>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@android:id/text1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:textSize="14sp"
android:textColor="?android:attr/textColorPrimary"
android:gravity="start|center_vertical"
android:minHeight="48dp"
android:background="?android:attr/selectableItemBackground" />

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@android:id/text1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:textSize="14sp"
android:textColor="?android:attr/textColorPrimary"
android:gravity="center_vertical"
android:ellipsize="marquee" />

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menu_logging_parameters"
android:title="Logging Parameters"
android:showAsAction="never" />
</menu>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menu_log_monitor"
android:title="Log Monitor"
android:showAsAction="never" />
<item
android:id="@+id/menu_open_external_log"
android:title="Open External Log"
android:showAsAction="never" />
<item
android:id="@+id/menu_user_guide"
android:title="@string/game_bar_user_guide"
android:showAsAction="never" />
</menu>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menu_open"
android:title="Open"
android:icon="@drawable/ic_open_in_new" />
<item
android:id="@+id/menu_share"
android:title="Share"
android:icon="@drawable/ic_share" />
<item
android:id="@+id/menu_export"
android:title="Export"
android:icon="@drawable/ic_export" />
<item
android:id="@+id/menu_delete"
android:title="Delete"
android:icon="@drawable/ic_delete" />
</menu>

108
res/values/arrays.xml Normal file
View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<resources>
<!-- FPS Overlay -->
<string-array name="game_bar_fps_method_entries">
<item>New API (Default)</item>
<item>Legacy Sysfs</item>
</string-array>
<string-array name="game_bar_fps_method_values">
<item>new</item>
<item>legacy</item>
</string-array>
<!-- Update Interval -->
<string-array name="fps_overlay_update_interval_entries">
<item>Every 500ms</item>
<item>Every second</item>
<item>Every 2 seconds</item>
<item>Every 5 seconds</item>
</string-array>
<string-array name="fps_overlay_update_interval_values">
<item>500</item>
<item>1000</item>
<item>2000</item>
<item>5000</item>
</string-array>
<!-- Position -->
<string-array name="fps_overlay_position_entries">
<item>Top Left</item>
<item>Top Center</item>
<item>Top Right</item>
<item>Bottom Left</item>
<item>Bottom Center</item>
<item>Bottom Right</item>
<item>Custom Draggable</item>
</string-array>
<string-array name="fps_overlay_position_values">
<item>top_left</item>
<item>top_center</item>
<item>top_right</item>
<item>bottom_left</item>
<item>bottom_center</item>
<item>bottom_right</item>
<item>draggable</item>
</string-array>
<!-- Overlay color -->
<string-array name="fps_overlay_color_entries">
<item>White</item>
<item>Crimson</item>
<item>Fruit Salad</item>
<item>Royal Blue</item>
<item>Amber</item>
<item>Teal</item>
<item>Electric Violet</item>
<item>Magenta</item>
</string-array>
<string-array name="fps_overlay_color_values">
<item>#FFFFFF</item>
<item>#DC143C</item>
<item>#4CAF50</item>
<item>#4169E1</item>
<item>#FFBF00</item>
<item>#008080</item>
<item>#8A2BE2</item>
<item>#FF1493</item>
</string-array>
<!-- Overlay format -->
<string-array name="game_bar_format_entries">
<item>Full</item>
<item>Minimal</item>
</string-array>
<string-array name="game_bar_format_values">
<item>full</item>
<item>minimal</item>
</string-array>
<!-- Split Mode -->
<string-array name="game_bar_split_mode_entries">
<item>Side-by-Side</item>
<item>Stacked</item>
</string-array>
<string-array name="game_bar_split_mode_values">
<item>side_by_side</item>
<item>stacked</item>
</string-array>
<!-- Long press timeouts -->
<string-array name="game_bar_longpress_entries">
<item>1 second</item>
<item>3 seconds</item>
<item>5 seconds</item>
<item>10 seconds</item>
</string-array>
<string-array name="game_bar_longpress_values">
<item>1000</item>
<item>3000</item>
<item>5000</item>
<item>10000</item>
</string-array>
</resources>

20
res/values/attrs.xml Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<resources>
<declare-styleable name="MinMaxSeekBarPreference">
<attr name="minValue" format="integer" />
<attr name="maxValue" format="integer" />
</declare-styleable>
<!-- Base attributes available to PartsCustomSeekBarPreference. -->
<declare-styleable name="PartsCustomSeekBarPreference">
<attr name="defaultValueText" format="string" />
<attr name="interval" format="integer" />
<attr name="showSign" format="boolean" />
<attr name="units" format="string|reference" />
<attr name="continuousUpdates" format="boolean" />
</declare-styleable>
</resources>

35
res/values/strings.xml Normal file
View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<!-- GameBar -->
<string name="app_title">GameBar</string>
<string name="tile_add">Add tile</string>
<string name="tile_added">Tile added</string>
<string name="tile_not_added">Tile not added</string>
<string name="tile_already_added">Tile already added</string>
<string name="tile_on">On</string>
<string name="tile_off">Off</string>
<!-- Custom seekbar -->
<string name="custom_seekbar_value">Value: <xliff:g id="v">%s</xliff:g></string>
<string name="custom_seekbar_default_value">by default</string>
<string name="custom_seekbar_default_value_to_set">Default value: <xliff:g id="v">%s</xliff:g>\nLong tap to set</string>
<string name="custom_seekbar_default_value_is_set">Default value is set</string>
<!-- GameBar Overlay -->
<string name="game_bar_title">GameBar</string>
<string name="game_bar_summary">Enable the system overlay (FPS, Temp, etc.)</string>
<string name="overlay_permission_required">Overlay permission is required</string>
<string name="overlay_permission_granted">Overlay permission granted</string>
<string name="overlay_permission_denied">Overlay permission denied</string>
<string name="game_bar_tile_label">GameBar</string>
<string name="game_bar_tile_description">Toggle the game overlay</string>
<string name="game_bar_user_guide">User Guide</string>
<string name="game_bar_user_guide_url">https://github.com/kenway214/packages_apps_GameBar/blob/main/GAMEBAR_USER_GUIDE.md</string>
</resources>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<paths>
<!-- External storage directory for log files -->
<external-path name="external_files" path="." />
<!-- External storage files directory -->
<external-files-path name="external_app_files" path="." />
<!-- Cache directory -->
<external-cache-path name="external_cache" path="." />
</paths>

View File

@@ -0,0 +1,257 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2025 kenway214
SPDX-License-Identifier: Apache-2.0
-->
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:settings="http://schemas.android.com/apk/res-auto">
<com.android.settingslib.widget.MainSwitchPreference
android:key="game_bar_enable"
android:title="Enable GameBar Overlay"
android:defaultValue="false" />
<com.android.gamebar.ExpandablePreferenceCategory
android:key="category_overlay_features"
android:title="Overlay Features"
android:dependency="game_bar_enable">
<SwitchPreferenceCompat
android:key="game_bar_fps_enable"
android:title="FPS Overlay"
android:summary="Show current FPS on screen"
android:defaultValue="true" />
<SwitchPreferenceCompat
android:key="game_bar_frame_time_enable"
android:title="Frame Time"
android:summary="Show frame time in milliseconds"
android:defaultValue="false" />
<SwitchPreferenceCompat
android:key="game_bar_temp_enable"
android:title="Device Temperature"
android:summary="Show device temperature"
android:defaultValue="false" />
<SwitchPreferenceCompat
android:key="game_bar_cpu_usage_enable"
android:title="CPU Usage"
android:summary="Show current CPU usage percentage"
android:defaultValue="true" />
<SwitchPreferenceCompat
android:key="game_bar_cpu_clock_enable"
android:title="CPU Clock Speeds"
android:summary="Show current CPU clock speeds for each core"
android:defaultValue="false" />
<SwitchPreferenceCompat
android:key="game_bar_cpu_temp_enable"
android:title="CPU Temperature"
android:summary="Show CPU temperature"
android:defaultValue="false" />
<SwitchPreferenceCompat
android:key="game_bar_ram_enable"
android:title="RAM Usage"
android:summary="Show current RAM usage in MB"
android:defaultValue="false" />
<SwitchPreferenceCompat
android:key="game_bar_ram_speed_enable"
android:title="RAM Frequency"
android:summary="Show current RAM frequency(bus) in GHz"
android:defaultValue="false" />
<SwitchPreferenceCompat
android:key="game_bar_ram_temp_enable"
android:title="RAM Temperature"
android:summary="Show current RAM temperature"
android:defaultValue="false" />
<SwitchPreferenceCompat
android:key="game_bar_gpu_usage_enable"
android:title="GPU Usage"
android:summary="Show GPU usage percentage"
android:defaultValue="true" />
<SwitchPreferenceCompat
android:key="game_bar_gpu_clock_enable"
android:title="GPU Clock Speed"
android:summary="Show current GPU clock frequency"
android:defaultValue="false" />
<SwitchPreferenceCompat
android:key="game_bar_gpu_temp_enable"
android:title="GPU Temperature"
android:summary="Show current GPU temperature"
android:defaultValue="false" />
</com.android.gamebar.ExpandablePreferenceCategory>
<com.android.gamebar.ExpandablePreferenceCategory
android:key="category_fps_method"
android:title="FPS Measurement Method"
android:dependency="game_bar_fps_enable">
<ListPreference
android:key="game_bar_fps_method"
android:title="Select FPS Method"
android:summary="Choose between the New API method (default) and Legacy Sysfs"
android:defaultValue="new"
android:entries="@array/game_bar_fps_method_entries"
android:entryValues="@array/game_bar_fps_method_values" />
</com.android.gamebar.ExpandablePreferenceCategory>
<com.android.gamebar.ExpandablePreferenceCategory
android:key="category_customization"
android:title="Customization"
android:dependency="game_bar_enable">
<com.android.gamebar.utils.PartsCustomSeekBarPreference
android:key="game_bar_text_size"
android:title="Text Size"
android:summary="Adjust the size of overlay text"
android:defaultValue="12"
android:max="50"
android:min="5"
settings:units="" />
<com.android.gamebar.utils.PartsCustomSeekBarPreference
android:key="game_bar_background_alpha"
android:title="Background Transparency"
android:summary="Adjust the transparency of the background"
android:defaultValue="95"
android:max="255"
android:min="0"
settings:units="" />
<com.android.gamebar.utils.PartsCustomSeekBarPreference
android:key="game_bar_corner_radius"
android:title="Overlay Corner Radius"
android:summary="Adjust how rounded the overlay corners should be"
android:defaultValue="100"
android:max="100"
android:min="0"
settings:units="" />
<com.android.gamebar.utils.PartsCustomSeekBarPreference
android:key="game_bar_padding"
android:title="Overlay Padding"
android:summary="Adjust the space around the stats"
android:defaultValue="4"
android:max="64"
android:min="0"
settings:units="" />
<com.android.gamebar.utils.PartsCustomSeekBarPreference
android:key="game_bar_item_spacing"
android:title="Item Spacing"
android:summary="Adjust spacing between overlay lines"
android:defaultValue="8"
android:max="50"
android:min="0"
settings:units="" />
<ListPreference
android:key="game_bar_update_interval"
android:title="Update Interval"
android:summary="Set how often the overlay values update"
android:defaultValue="1000"
android:entries="@array/fps_overlay_update_interval_entries"
android:entryValues="@array/fps_overlay_update_interval_values" />
<ListPreference
android:key="game_bar_title_color"
android:title="Stat Title Color"
android:summary="Color for 'FPS', 'Temp', 'CPU', etc. text"
android:defaultValue="#FFFFFF"
android:entries="@array/fps_overlay_color_entries"
android:entryValues="@array/fps_overlay_color_values" />
<ListPreference
android:key="game_bar_value_color"
android:title="Stat Value Color"
android:summary="Color for numeric stats (e.g., '29', '32.0°C')"
android:defaultValue="#4CAF50"
android:entries="@array/fps_overlay_color_entries"
android:entryValues="@array/fps_overlay_color_values" />
<ListPreference
android:key="game_bar_position"
android:title="Overlay Position"
android:summary="Select the position of the overlay on screen"
android:defaultValue="draggable"
android:entries="@array/fps_overlay_position_entries"
android:entryValues="@array/fps_overlay_position_values" />
<ListPreference
android:key="game_bar_format"
android:title="Overlay Format"
android:summary="Choose between Full or Minimal display"
android:defaultValue="full"
android:entries="@array/game_bar_format_entries"
android:entryValues="@array/game_bar_format_values" />
</com.android.gamebar.ExpandablePreferenceCategory>
<com.android.gamebar.ExpandablePreferenceCategory
android:key="category_split_config"
android:title="Split Config"
android:dependency="game_bar_enable">
<ListPreference
android:key="game_bar_split_mode"
android:title="Split Mode"
android:summary="Choose Side-by-Side or Stacked arrangement"
android:defaultValue="side_by_side"
android:entries="@array/game_bar_split_mode_entries"
android:entryValues="@array/game_bar_split_mode_values" />
</com.android.gamebar.ExpandablePreferenceCategory>
<com.android.gamebar.ExpandablePreferenceCategory
android:key="category_gesture_controls"
android:title="Overlay Gesture Controls"
android:dependency="game_bar_enable">
<SwitchPreferenceCompat
android:key="game_bar_single_tap_toggle"
android:title="Enable Single Tap to Toggle"
android:summary="Tap once to switch between full and minimal overlay formats"
android:defaultValue="true" />
<SwitchPreferenceCompat
android:key="game_bar_doubletap_capture"
android:title="Enable Double Tap to Capture"
android:summary="Double-tap overlay to start/stop capture logs"
android:defaultValue="true" />
<SwitchPreferenceCompat
android:key="game_bar_longpress_enable"
android:title="Enable Long Press"
android:summary="Long-press overlay to access GameBar settings"
android:defaultValue="true" />
<ListPreference
android:key="game_bar_longpress_timeout"
android:title="Long Press Duration"
android:summary="Set the duration required to long-press the overlay"
android:defaultValue="500"
android:entries="@array/game_bar_longpress_entries"
android:entryValues="@array/game_bar_longpress_values"
android:dependency="game_bar_longpress_enable" />
</com.android.gamebar.ExpandablePreferenceCategory>
<com.android.gamebar.ExpandablePreferenceCategory
android:key="category_per_app"
android:title="Per-App GameBar">
<SwitchPreferenceCompat
android:key="game_bar_auto_enable"
android:title="Auto-Enable GameBar for Selected Apps"
android:summary="If turned on, selected apps will auto-enable GameBar even if the main switch is off"
android:defaultValue="false" />
<Preference
android:key="game_bar_per_app_config"
android:title="Configure Apps"
android:summary="Choose which apps will auto-enable GameBar" />
</com.android.gamebar.ExpandablePreferenceCategory>
</PreferenceScreen>

8
sepolicy/SEPolicy.mk Normal file
View File

@@ -0,0 +1,8 @@
# GameBar sepolicy
BOARD_VENDOR_SEPOLICY_DIRS += \
packages/apps/GameBar/sepolicy/vendor
PRODUCT_PRIVATE_SEPOLICY_DIRS += \
packages/apps/GameBar/sepolicy/private
PRODUCT_PUBLIC_SEPOLICY_DIRS += \
packages/apps/GameBar/sepolicy/public

View File

@@ -0,0 +1,40 @@
app_domain(gamebar_app)
binder_use(gamebar_app)
allow gamebar_app {
activity_service
activity_task_service
app_api_service
batterystats_service
cameraserver_service
content_capture_service
device_state_service
drmserver_service
game_service
gpu_service
hint_service
media_session_service
mediaextractor_service
mediametrics_service
mediaserver_service
netstats_service
permission_checker_service
sensorservice_service
statusbar_service
thermal_service
}:service_manager find;
allow gamebar_app system_app_data_file:dir create_dir_perms;
allow gamebar_app system_app_data_file:{ file lnk_file } create_file_perms;
binder_call(gamebar_app, gpuservice)
get_prop(gamebar_app, settingslib_prop)
# Allow reading thermal sensor data
r_dir_file(gamebar_app, sysfs_thermal)
allow gamebar_app sysfs_thermal:file { read open };
allow gamebar_app sysfs:file { read open };
allow gamebar_app sysfs:file { read open getattr ioctl };
allow gamebar_app proc_stat:file r_file_perms;
allow gamebar_app system_server:binder { call transfer };

View File

@@ -0,0 +1 @@
settingsdebug.instant.packages u:object_r:settingslib_prop:s0

View File

@@ -0,0 +1,3 @@
#GameBar
user=system seinfo=platform isPrivApp=true name=com.android.gamebar domain=gamebar_app type=system_app_data_file levelFrom=all

View File

@@ -0,0 +1,2 @@
type gamebar_app, domain;
typeattribute gamebar_app mlstrustedsubject;

View File

@@ -0,0 +1,2 @@
# SettingsLib
system_public_prop(settingslib_prop)

2
sepolicy/vendor/file_contexts vendored Normal file
View File

@@ -0,0 +1,2 @@
# Process and system statistics files
/proc/stat u:object_r:proc_stat:s0

8
sepolicy/vendor/gamebar_app.te vendored Normal file
View File

@@ -0,0 +1,8 @@
allow gamebar_app vendor_sysfs_graphics:dir search;
allow gamebar_app vendor_sysfs_graphics:file rw_file_perms;
allow gamebar_app vendor_sysfs_kgsl:dir search;
allow gamebar_app vendor_sysfs_kgsl:{ file lnk_file } rw_file_perms;
allow gamebar_app vendor_sysfs_battery_supply:dir search;
allow gamebar_app vendor_sysfs_battery_supply:file r_file_perms;
allow gamebar_app proc_stat:file { read open getattr };
allow gamebar_app vendor_sysfs_kgsl_gpuclk:file { read open getattr };

View File

@@ -0,0 +1,220 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View
import kotlin.math.max
class CpuClockGraphView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
// Color palette for CPU cores (8 distinct colors)
private val coreColors = arrayOf(
"#FF5252", // Red
"#FF9800", // Orange
"#FFEB3B", // Yellow
"#4CAF50", // Green
"#2196F3", // Blue
"#9C27B0", // Purple
"#E91E63", // Pink
"#00BCD4" // Cyan
)
private val corePaints = mutableListOf<Paint>()
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#30FFFFFF")
strokeWidth = 1f
style = Paint.Style.STROKE
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FFFFFF")
textSize = 24f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
}
private val legendTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FFFFFF")
textSize = 20f
}
// Data: Map of coreIndex -> List<Pair<timestamp, clockMhz>>
private var clockData: Map<Int, List<Pair<Long, Double>>> = emptyMap()
private var maxClock = 3000.0 // Dynamic max
private var numCores = 8
private val padding = 80f
private val topPadding = 60f
private val bottomPadding = 120f // Extra space for legend
init {
// Initialize paints for each core
for (color in coreColors) {
corePaints.add(Paint(Paint.ANTI_ALIAS_FLAG).apply {
this.color = Color.parseColor(color)
strokeWidth = 2.5f
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
})
}
}
fun setData(data: Map<Int, List<Pair<Long, Double>>>) {
this.clockData = data
this.numCores = data.keys.size
// Calculate dynamic max (round up to nearest 500)
if (data.isNotEmpty()) {
val dataMax = data.values.flatten().maxOfOrNull { it.second } ?: 3000.0
maxClock = ((dataMax / 500).toInt() + 1) * 500.0
}
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (clockData.isEmpty()) {
drawEmptyState(canvas)
return
}
val graphWidth = width - 2 * padding
val graphHeight = height - topPadding - bottomPadding
drawGrid(canvas, graphWidth, graphHeight)
drawGraphs(canvas, graphWidth, graphHeight)
drawTitle(canvas)
drawLegend(canvas)
}
private fun drawEmptyState(canvas: Canvas) {
val message = "No CPU clock data available"
val textWidth = textPaint.measureText(message)
canvas.drawText(
message,
(width - textWidth) / 2,
height / 2f,
textPaint
)
}
private fun drawGrid(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
// Calculate clock steps
val step = (maxClock / 4).toInt()
val clockSteps = (0..4).map { it * step }
for (clock in clockSteps) {
val y = (topPadding + graphHeight * (1 - clock / maxClock)).toFloat()
canvas.drawLine(padding, y, padding + graphWidth, y, gridPaint)
val label = "${clock}MHz"
canvas.drawText(label, padding - 75f, y + 8f, textPaint)
}
// Y-axis label
canvas.save()
canvas.rotate(-90f, 15f, height / 2f)
val yLabel = "Clock Speed (MHz)"
val yLabelWidth = textPaint.measureText(yLabel)
canvas.drawText(yLabel, (width - yLabelWidth) / 2, 30f, textPaint)
canvas.restore()
// Time labels
if (clockData.isNotEmpty() && clockData.values.first().isNotEmpty()) {
val firstData = clockData.values.first()
val startTime = firstData.first().first
val endTime = firstData.last().first
val duration = endTime - startTime
canvas.drawText("0s", padding, height - bottomPadding + 25f, textPaint)
val middleTime = formatDuration(duration / 2)
canvas.drawText(middleTime, padding + graphWidth / 2 - 30f, height - bottomPadding + 25f, textPaint)
val endTimeStr = formatDuration(duration)
val endX = padding + graphWidth - textPaint.measureText(endTimeStr)
canvas.drawText(endTimeStr, endX, height - bottomPadding + 25f, textPaint)
}
val xLabel = "Time"
val xLabelWidth = textPaint.measureText(xLabel)
canvas.drawText(xLabel, (width - xLabelWidth) / 2, height - bottomPadding + 55f, textPaint)
}
private fun drawGraphs(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
clockData.forEach { (coreIndex, data) ->
if (data.size < 2) return@forEach
val paint = corePaints[coreIndex % corePaints.size]
val startTime = data.first().first
val endTime = data.last().first
val timeDuration = max(endTime - startTime, 1L)
val path = Path()
data.forEachIndexed { index, (timestamp, clock) ->
val x = padding + ((timestamp - startTime).toFloat() / timeDuration) * graphWidth
val y = (topPadding + graphHeight * (1 - clock / maxClock)).toFloat()
if (index == 0) {
path.moveTo(x, y)
} else {
path.lineTo(x, y)
}
}
canvas.drawPath(path, paint)
}
}
private fun drawTitle(canvas: Canvas) {
val title = "CPU Clock Speed vs Time (Per Core)"
val titleWidth = textPaint.measureText(title)
canvas.drawText(title, (width - titleWidth) / 2, topPadding - 15f, textPaint)
}
private fun drawLegend(canvas: Canvas) {
val legendY = height - bottomPadding + 85f
val legendStartX = padding
val itemWidth = (width - 2 * padding) / 4 // 4 items per row
for (i in 0 until numCores) {
val col = i % 4
val row = i / 4
val x = legendStartX + col * itemWidth
val y = legendY + row * 30f
val paint = corePaints[i % corePaints.size]
// Draw color box
canvas.drawRect(x, y - 12f, x + 20f, y, paint)
// Draw label
canvas.drawText("CPU$i", x + 25f, y, legendTextPaint)
}
}
private fun formatDuration(durationMs: Long): String {
val seconds = durationMs / 1000
return if (seconds < 60) {
"${seconds}s"
} else {
val minutes = seconds / 60
val remainingSeconds = seconds % 60
"${minutes}m ${remainingSeconds}s"
}
}
}

View File

@@ -0,0 +1,204 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View
import kotlin.math.max
class CpuGraphView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#2196F3") // Blue for CPU
strokeWidth = 3f
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(
0f, 0f, 0f, 600f,
Color.parseColor("#802196F3"),
Color.parseColor("#00000000"),
Shader.TileMode.CLAMP
)
style = Paint.Style.FILL
}
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#30FFFFFF")
strokeWidth = 1f
style = Paint.Style.STROKE
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FFFFFF")
textSize = 28f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
}
private val avgLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FF9800")
strokeWidth = 2f
style = Paint.Style.STROKE
pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
}
private var cpuData: List<Pair<Long, Double>> = emptyList()
private var avgCpu: Double = 0.0
private val maxCpu = 100.0
private val minCpu = 0.0
private val padding = 80f
private val topPadding = 40f
private val bottomPadding = 80f
fun setData(data: List<Pair<Long, Double>>, avg: Double) {
this.cpuData = data
this.avgCpu = avg
post {
fillPaint.shader = LinearGradient(
0f, topPadding, 0f, height - bottomPadding,
Color.parseColor("#802196F3"),
Color.parseColor("#102196F3"),
Shader.TileMode.CLAMP
)
invalidate()
}
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (cpuData.isEmpty()) {
drawEmptyState(canvas)
return
}
val graphWidth = width - 2 * padding
val graphHeight = height - topPadding - bottomPadding
drawGrid(canvas, graphWidth, graphHeight)
drawAverageLine(canvas, graphWidth, graphHeight)
drawGraph(canvas, graphWidth, graphHeight)
drawTitle(canvas)
}
private fun drawEmptyState(canvas: Canvas) {
val message = "No CPU data available"
val textWidth = textPaint.measureText(message)
canvas.drawText(
message,
(width - textWidth) / 2,
height / 2f,
textPaint
)
}
private fun drawGrid(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
val cpuSteps = listOf(0, 25, 50, 75, 100)
for (cpu in cpuSteps) {
val y = (topPadding + graphHeight * (1 - cpu / maxCpu.toFloat())).toFloat()
canvas.drawLine(padding, y, padding + graphWidth, y, gridPaint)
val label = "$cpu%"
canvas.drawText(label, padding - 70f, y + 10f, textPaint)
}
// Y-axis label
canvas.save()
canvas.rotate(-90f, 15f, height / 2f)
val yLabel = "CPU Usage (%)"
val yLabelWidth = textPaint.measureText(yLabel)
canvas.drawText(yLabel, (width - yLabelWidth) / 2, 30f, textPaint)
canvas.restore()
// Time labels on X-axis
if (cpuData.isNotEmpty()) {
val startTime = cpuData.first().first
val endTime = cpuData.last().first
val duration = endTime - startTime
canvas.drawText("0s", padding, height - bottomPadding + 25f, textPaint)
val middleTime = formatDuration(duration / 2)
canvas.drawText(middleTime, padding + graphWidth / 2 - 30f, height - bottomPadding + 25f, textPaint)
val endTimeStr = formatDuration(duration)
val endX = padding + graphWidth - textPaint.measureText(endTimeStr)
canvas.drawText(endTimeStr, endX, height - bottomPadding + 25f, textPaint)
}
val xLabel = "Time"
val xLabelWidth = textPaint.measureText(xLabel)
canvas.drawText(xLabel, (width - xLabelWidth) / 2, height - bottomPadding + 55f, textPaint)
}
private fun drawAverageLine(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
val y = (topPadding + graphHeight * (1 - avgCpu / maxCpu)).toFloat()
canvas.drawLine(padding, y, padding + graphWidth, y, avgLinePaint)
}
private fun drawGraph(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
if (cpuData.size < 2) return
val startTime = cpuData.first().first
val endTime = cpuData.last().first
val timeDuration = max(endTime - startTime, 1L)
val path = Path()
val fillPath = Path()
cpuData.forEachIndexed { index, (timestamp, cpu) ->
val x = padding + ((timestamp - startTime).toFloat() / timeDuration) * graphWidth
val y = (topPadding + graphHeight * (1 - cpu / maxCpu)).toFloat()
if (index == 0) {
path.moveTo(x, y)
fillPath.moveTo(x, height - bottomPadding)
fillPath.lineTo(x, y)
} else {
path.lineTo(x, y)
fillPath.lineTo(x, y)
}
}
fillPath.lineTo(padding + graphWidth, height - bottomPadding)
fillPath.close()
canvas.drawPath(fillPath, fillPaint)
canvas.drawPath(path, linePaint)
}
private fun drawTitle(canvas: Canvas) {
val title = "CPU Usage vs Time"
val titleWidth = textPaint.measureText(title)
canvas.drawText(title, (width - titleWidth) / 2, topPadding - 10f, textPaint)
}
private fun formatDuration(durationMs: Long): String {
val seconds = durationMs / 1000
return if (seconds < 60) {
"${seconds}s"
} else {
val minutes = seconds / 60
val remainingSeconds = seconds % 60
"${minutes}m ${remainingSeconds}s"
}
}
}

View File

@@ -0,0 +1,212 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View
import kotlin.math.max
class CpuTempGraphView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FF5722") // Orange/Red for temperature
strokeWidth = 3f
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(
0f, 0f, 0f, 600f,
Color.parseColor("#80FF5722"),
Color.parseColor("#00000000"),
Shader.TileMode.CLAMP
)
style = Paint.Style.FILL
}
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#30FFFFFF")
strokeWidth = 1f
style = Paint.Style.STROKE
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FFFFFF")
textSize = 28f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
}
private val avgLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FF9800")
strokeWidth = 2f
style = Paint.Style.STROKE
pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
}
private var tempData: List<Pair<Long, Double>> = emptyList()
private var avgTemp: Double = 0.0
private var maxTemp = 100.0 // Dynamic max based on data
private var minTemp = 0.0
private val padding = 80f
private val topPadding = 40f
private val bottomPadding = 80f
fun setData(data: List<Pair<Long, Double>>, avg: Double) {
this.tempData = data
this.avgTemp = avg
// Calculate dynamic max (round up to nearest 10)
if (data.isNotEmpty()) {
val dataMax = data.maxOf { it.second }
maxTemp = ((dataMax / 10).toInt() + 1) * 10.0
}
post {
fillPaint.shader = LinearGradient(
0f, topPadding, 0f, height - bottomPadding,
Color.parseColor("#80FF5722"),
Color.parseColor("#10FF5722"),
Shader.TileMode.CLAMP
)
invalidate()
}
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (tempData.isEmpty()) {
drawEmptyState(canvas)
return
}
val graphWidth = width - 2 * padding
val graphHeight = height - topPadding - bottomPadding
drawGrid(canvas, graphWidth, graphHeight)
drawAverageLine(canvas, graphWidth, graphHeight)
drawGraph(canvas, graphWidth, graphHeight)
drawTitle(canvas)
}
private fun drawEmptyState(canvas: Canvas) {
val message = "No temperature data available"
val textWidth = textPaint.measureText(message)
canvas.drawText(
message,
(width - textWidth) / 2,
height / 2f,
textPaint
)
}
private fun drawGrid(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
// Calculate temp steps based on maxTemp
val step = (maxTemp / 4).toInt()
val tempSteps = (0..4).map { it * step }
for (temp in tempSteps) {
val y = (topPadding + graphHeight * (1 - temp / maxTemp)).toFloat()
canvas.drawLine(padding, y, padding + graphWidth, y, gridPaint)
val label = "${temp}°C"
canvas.drawText(label, padding - 70f, y + 10f, textPaint)
}
// Y-axis label
canvas.save()
canvas.rotate(-90f, 15f, height / 2f)
val yLabel = "Temperature (°C)"
val yLabelWidth = textPaint.measureText(yLabel)
canvas.drawText(yLabel, (width - yLabelWidth) / 2, 30f, textPaint)
canvas.restore()
// Time labels
if (tempData.isNotEmpty()) {
val startTime = tempData.first().first
val endTime = tempData.last().first
val duration = endTime - startTime
canvas.drawText("0s", padding, height - bottomPadding + 25f, textPaint)
val middleTime = formatDuration(duration / 2)
canvas.drawText(middleTime, padding + graphWidth / 2 - 30f, height - bottomPadding + 25f, textPaint)
val endTimeStr = formatDuration(duration)
val endX = padding + graphWidth - textPaint.measureText(endTimeStr)
canvas.drawText(endTimeStr, endX, height - bottomPadding + 25f, textPaint)
}
val xLabel = "Time"
val xLabelWidth = textPaint.measureText(xLabel)
canvas.drawText(xLabel, (width - xLabelWidth) / 2, height - bottomPadding + 55f, textPaint)
}
private fun drawAverageLine(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
val y = (topPadding + graphHeight * (1 - avgTemp / maxTemp)).toFloat()
canvas.drawLine(padding, y, padding + graphWidth, y, avgLinePaint)
}
private fun drawGraph(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
if (tempData.size < 2) return
val startTime = tempData.first().first
val endTime = tempData.last().first
val timeDuration = max(endTime - startTime, 1L)
val path = Path()
val fillPath = Path()
tempData.forEachIndexed { index, (timestamp, temp) ->
val x = padding + ((timestamp - startTime).toFloat() / timeDuration) * graphWidth
val y = (topPadding + graphHeight * (1 - temp / maxTemp)).toFloat()
if (index == 0) {
path.moveTo(x, y)
fillPath.moveTo(x, height - bottomPadding)
fillPath.lineTo(x, y)
} else {
path.lineTo(x, y)
fillPath.lineTo(x, y)
}
}
fillPath.lineTo(padding + graphWidth, height - bottomPadding)
fillPath.close()
canvas.drawPath(fillPath, fillPaint)
canvas.drawPath(path, linePaint)
}
private fun drawTitle(canvas: Canvas) {
val title = "CPU Temperature vs Time"
val titleWidth = textPaint.measureText(title)
canvas.drawText(title, (width - titleWidth) / 2, topPadding - 10f, textPaint)
}
private fun formatDuration(durationMs: Long): String {
val seconds = durationMs / 1000
return if (seconds < 60) {
"${seconds}s"
} else {
val minutes = seconds / 60
val remainingSeconds = seconds % 60
"${minutes}m ${remainingSeconds}s"
}
}
}

View File

@@ -0,0 +1,94 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.Context
import android.util.AttributeSet
import android.widget.ImageView
import android.widget.TextView
import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceViewHolder
import com.android.gamebar.R
class ExpandablePreferenceCategory @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = androidx.preference.R.attr.preferenceCategoryStyle,
defStyleRes: Int = 0
) : PreferenceCategory(context, attrs, defStyleAttr, defStyleRes) {
private var isExpanded = false
init {
layoutResource = R.layout.preference_category_expandable
isIconSpaceReserved = false
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
val expandIcon = holder.findViewById(R.id.expand_icon) as? ImageView
val titleView = holder.findViewById(android.R.id.title) as? TextView
// Update icon rotation based on expanded state with animation
expandIcon?.animate()
?.rotation(if (isExpanded) 90f else 0f)
?.setDuration(200)
?.start()
// Make the entire view clickable
holder.itemView.isClickable = true
holder.itemView.isFocusable = true
holder.itemView.setOnClickListener {
toggleExpanded()
}
// Update title style
titleView?.let {
it.textSize = 14f
it.setTextColor(context.getColor(android.R.color.white))
}
}
override fun onAttached() {
super.onAttached()
// Initially collapse all children
if (!isExpanded) {
syncChildrenVisibility()
}
}
private fun toggleExpanded() {
isExpanded = !isExpanded
syncChildrenVisibility()
notifyChanged()
}
private fun syncChildrenVisibility() {
for (i in 0 until preferenceCount) {
getPreference(i)?.isVisible = isExpanded
}
}
fun setExpanded(expanded: Boolean) {
if (isExpanded != expanded) {
isExpanded = expanded
syncChildrenVisibility()
notifyChanged()
}
}
override fun addPreference(preference: Preference): Boolean {
val result = super.addPreference(preference)
if (result && !isExpanded) {
preference.isVisible = false
}
return result
}
}

View File

@@ -0,0 +1,104 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.app.ActivityManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import java.lang.reflect.Method
object ForegroundAppDetector {
private const val TAG = "ForegroundAppDetector"
private var lastKnownPackage = "Unknown"
private var lastUpdateTime = 0L
private const val CACHE_TIMEOUT = 500L // Reduce cache timeout
// Simple reflection caching
private var reflectionSetupFailed = false
fun getForegroundPackageName(context: Context): String {
// Use cached result if still valid
val currentTime = System.currentTimeMillis()
if (currentTime - lastUpdateTime < CACHE_TIMEOUT && lastKnownPackage != "Unknown") {
return lastKnownPackage
}
val pkg = tryGetRunningTasks(context)
if (pkg != null) {
lastKnownPackage = pkg
lastUpdateTime = currentTime
return pkg
}
if (!reflectionSetupFailed) {
val reflectionPkg = tryReflectActivityTaskManager()
if (reflectionPkg != null) {
lastKnownPackage = reflectionPkg
lastUpdateTime = currentTime
return reflectionPkg
}
}
// Return cached value if available, otherwise "Unknown"
return lastKnownPackage
}
private fun tryGetRunningTasks(context: Context): String? {
try {
if (context.checkSelfPermission("android.permission.GET_TASKS") == PackageManager.PERMISSION_GRANTED) {
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val tasks = am.getRunningTasks(1)
if (tasks.isNotEmpty()) {
val top = tasks[0]
top.topActivity?.let {
return it.packageName
}
}
} else {
Log.w(TAG, "GET_TASKS permission not granted to this system app?")
}
} catch (e: Exception) {
Log.e(TAG, "tryGetRunningTasks error: ", e)
}
return null
}
private fun tryReflectActivityTaskManager(): String? {
try {
if (reflectionSetupFailed) {
return null
}
val atmClass = Class.forName("android.app.ActivityTaskManager")
val getServiceMethod = atmClass.getDeclaredMethod("getService")
getServiceMethod.isAccessible = true
val atmService = getServiceMethod.invoke(null)
val getTasksMethod = atmService.javaClass.getMethod("getTasks", Int::class.java)
@Suppress("UNCHECKED_CAST")
val taskList = getTasksMethod.invoke(atmService, 1) as? List<*>
if (!taskList.isNullOrEmpty()) {
val firstTask = taskList[0]
val rtiClass = firstTask!!.javaClass
val getTopActivityMethod = rtiClass.getDeclaredMethod("getTopActivity")
val compName = getTopActivityMethod.invoke(firstTask)
if (compName != null) {
val getPackageNameMethod = compName.javaClass.getMethod("getPackageName")
val pkgName = getPackageNameMethod.invoke(compName) as String
return pkgName
}
}
} catch (e: Exception) {
Log.e(TAG, "tryReflectActivityTaskManager error: ", e)
reflectionSetupFailed = true // Disable reflection on error
}
return null
}
}

View File

@@ -0,0 +1,318 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View
import kotlin.math.max
import kotlin.math.min
class FpsGraphView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#4CAF50")
strokeWidth = 3f
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(
0f, 0f, 0f, 800f,
Color.parseColor("#804CAF50"),
Color.parseColor("#00000000"),
Shader.TileMode.CLAMP
)
style = Paint.Style.FILL
}
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#30FFFFFF")
strokeWidth = 1f
style = Paint.Style.STROKE
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FFFFFF")
textSize = 28f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
}
private val avgLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FF9800")
strokeWidth = 2f
style = Paint.Style.STROKE
pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
}
private val lowLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#F44336")
strokeWidth = 2f
style = Paint.Style.STROKE
pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
}
private var fpsData: List<Pair<Long, Double>> = emptyList()
private var avgFps: Double = 0.0
private var fps1PercentLow: Double = 0.0
private val maxFps = 144.0 // Absolute max for display
private val functionalMaxFps = 120.0 // Treat 120 as 100%
private val minFps = 0.0
private val padding = 80f
private val topPadding = 40f
private val bottomPadding = 80f // Increased for axis label
fun setData(data: List<Pair<Long, Double>>, avg: Double, low1Percent: Double) {
this.fpsData = data
this.avgFps = avg
this.fps1PercentLow = low1Percent
// Update gradient shader with actual view height
post {
fillPaint.shader = LinearGradient(
0f, topPadding, 0f, height - bottomPadding,
Color.parseColor("#804CAF50"),
Color.parseColor("#104CAF50"),
Shader.TileMode.CLAMP
)
invalidate()
}
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (fpsData.isEmpty()) {
drawEmptyState(canvas)
return
}
val graphWidth = width - 2 * padding
val graphHeight = height - topPadding - bottomPadding
// Draw grid and labels
drawGrid(canvas, graphWidth, graphHeight)
// Draw average line
drawAverageLine(canvas, graphWidth, graphHeight)
// Draw 1% low line
draw1PercentLowLine(canvas, graphWidth, graphHeight)
// Draw FPS graph
drawGraph(canvas, graphWidth, graphHeight)
// Draw legend
drawLegend(canvas)
}
private fun drawEmptyState(canvas: Canvas) {
val message = "No data to display"
val textWidth = textPaint.measureText(message)
canvas.drawText(
message,
(width - textWidth) / 2,
height / 2f,
textPaint
)
}
private fun drawGrid(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
// Draw horizontal grid lines (FPS) - using 120 as functional max (100%)
val fpsSteps = listOf(0, 30, 60, 90, 120, 144)
for (fps in fpsSteps) {
// Map FPS values so 120 appears at 100% height
val normalizedFps = if (fps <= 120) {
fps.toFloat()
} else {
// Squeeze 120-144 into the remaining space
120f + (fps - 120) * 0.2f
}
val y = (topPadding + graphHeight * (1 - normalizedFps / (functionalMaxFps.toFloat() + 4.8f))).toFloat()
// Grid line
canvas.drawLine(padding, y, padding + graphWidth, y, gridPaint)
// Label
val label = "$fps"
canvas.drawText(label, padding - 60f, y + 10f, textPaint)
}
// Draw Y-axis label (far left, separate from numbers)
canvas.save()
canvas.rotate(-90f, 15f, height / 2f)
val yLabel = "FPS"
val yLabelWidth = textPaint.measureText(yLabel)
canvas.drawText(yLabel, (width - yLabelWidth) / 2, 30f, textPaint) // Position at x=30 (far left)
canvas.restore()
// Draw time labels on X-axis (higher up - first line)
if (fpsData.isNotEmpty()) {
val startTime = fpsData.first().first
val endTime = fpsData.last().first
val duration = endTime - startTime
// Draw start time
canvas.drawText("0s", padding, height - bottomPadding + 25f, textPaint)
// Draw middle time
val middleTime = formatDuration(duration / 2)
val middleX = padding + graphWidth / 2
canvas.drawText(middleTime, middleX - 30f, height - bottomPadding + 25f, textPaint)
// Draw end time
val endTimeStr = formatDuration(duration)
val endX = padding + graphWidth - textPaint.measureText(endTimeStr)
canvas.drawText(endTimeStr, endX, height - bottomPadding + 25f, textPaint)
}
// Draw X-axis label (second line - below time values)
val xLabel = "Time"
val xLabelWidth = textPaint.measureText(xLabel)
canvas.drawText(xLabel, (width - xLabelWidth) / 2, height - bottomPadding + 55f, textPaint)
}
private fun drawGraph(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
if (fpsData.size < 2) return
val startTime = fpsData.first().first
val endTime = fpsData.last().first
val timeDuration = max(endTime - startTime, 1L)
val path = Path()
val fillPath = Path()
fpsData.forEachIndexed { index, (timestamp, fps) ->
val x = padding + ((timestamp - startTime).toFloat() / timeDuration) * graphWidth
// Normalize FPS using 120 as functional max
val normalizedFps = max(minFps, min(maxFps, fps))
val mappedFps = if (normalizedFps <= 120.0) {
normalizedFps.toFloat()
} else {
// Squeeze 120-144 range into remaining space
120f + (normalizedFps.toFloat() - 120f) * 0.2f
}
val y = (topPadding + graphHeight * (1 - mappedFps / (functionalMaxFps.toFloat() + 4.8f))).toFloat()
if (index == 0) {
path.moveTo(x, y)
fillPath.moveTo(x, height - bottomPadding)
fillPath.lineTo(x, y)
} else {
path.lineTo(x, y)
fillPath.lineTo(x, y)
}
}
// Complete fill path
val lastX = padding + graphWidth
fillPath.lineTo(lastX, height - bottomPadding)
fillPath.close()
// Draw fill
canvas.drawPath(fillPath, fillPaint)
// Draw line
canvas.drawPath(path, linePaint)
}
private fun drawAverageLine(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
// Map average line position
val normalizedAvg = max(minFps, min(maxFps, avgFps))
val mappedAvg = if (normalizedAvg <= 120.0) {
normalizedAvg.toFloat()
} else {
120f + (normalizedAvg.toFloat() - 120f) * 0.2f
}
val y = (topPadding + graphHeight * (1 - mappedAvg / (functionalMaxFps.toFloat() + 4.8f))).toFloat()
canvas.drawLine(padding, y, padding + graphWidth, y, avgLinePaint)
}
private fun draw1PercentLowLine(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
// Map 1% low line position
val normalizedLow = max(minFps, min(maxFps, fps1PercentLow))
val mappedLow = if (normalizedLow <= 120.0) {
normalizedLow.toFloat()
} else {
120f + (normalizedLow.toFloat() - 120f) * 0.2f
}
val y = (topPadding + graphHeight * (1 - mappedLow / (functionalMaxFps.toFloat() + 4.8f))).toFloat()
canvas.drawLine(padding, y, padding + graphWidth, y, lowLinePaint)
}
private fun drawLegend(canvas: Canvas) {
val legendX = padding
val legendY = 20f
val lineLength = 40f
val spacing = 150f
// FPS line
canvas.drawLine(legendX, legendY, legendX + lineLength, legendY, linePaint)
canvas.drawText("FPS", legendX + lineLength + 10f, legendY + 8f, textPaint.apply { textSize = 24f })
// Average line
canvas.drawLine(legendX + spacing, legendY, legendX + spacing + lineLength, legendY, avgLinePaint)
canvas.drawText("Avg", legendX + spacing + lineLength + 10f, legendY + 8f, textPaint)
// 1% Low line
canvas.drawLine(legendX + spacing * 2, legendY, legendX + spacing * 2 + lineLength, legendY, lowLinePaint)
canvas.drawText("1% Low", legendX + spacing * 2 + lineLength + 10f, legendY + 8f, textPaint)
// Reset text size
textPaint.textSize = 28f
}
private fun formatDuration(durationMs: Long): String {
val seconds = durationMs / 1000
val minutes = seconds / 60
val hours = minutes / 60
return when {
hours > 0 -> String.format("%dh%dm", hours, minutes % 60)
minutes > 0 -> String.format("%dm%ds", minutes, seconds % 60)
else -> String.format("%ds", seconds)
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredWidth = 800
val desiredHeight = 600
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
val width = when (widthMode) {
MeasureSpec.EXACTLY -> widthSize
MeasureSpec.AT_MOST -> min(desiredWidth, widthSize)
else -> desiredWidth
}
val height = when (heightMode) {
MeasureSpec.EXACTLY -> heightSize
MeasureSpec.AT_MOST -> min(desiredHeight, heightSize)
else -> desiredHeight
}
setMeasuredDimension(width, height)
}
}

View File

@@ -0,0 +1,275 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View
import kotlin.math.max
import kotlin.math.min
class FrameTimeGraphView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FFC107") // Amber for frame time
strokeWidth = 3f
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(
0f, 0f, 0f, 800f,
Color.parseColor("#80FFC107"),
Color.parseColor("#00000000"),
Shader.TileMode.CLAMP
)
style = Paint.Style.FILL
}
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#30FFFFFF")
strokeWidth = 1f
style = Paint.Style.STROKE
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FFFFFF")
textSize = 28f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
}
private val avgLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FF9800")
strokeWidth = 2f
style = Paint.Style.STROKE
pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
}
private val targetLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#4CAF50") // Green for 60fps target (16.67ms)
strokeWidth = 2f
style = Paint.Style.STROKE
pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
}
private var frameTimeData: List<Pair<Long, Double>> = emptyList()
private var avgFrameTime: Double = 0.0
private var maxFrameTime = 50.0 // Dynamic max
private var minFrameTime = 0.0
private val padding = 80f
private val topPadding = 40f
private val bottomPadding = 80f
fun setData(data: List<Pair<Long, Double>>, avg: Double) {
this.frameTimeData = data
this.avgFrameTime = avg
// Calculate dynamic max (round up to nearest 10)
if (data.isNotEmpty()) {
val dataMax = data.maxOf { it.second }
maxFrameTime = max(((dataMax / 10).toInt() + 1) * 10.0, 50.0)
}
post {
fillPaint.shader = LinearGradient(
0f, topPadding, 0f, height - bottomPadding,
Color.parseColor("#80FFC107"),
Color.parseColor("#10FFC107"),
Shader.TileMode.CLAMP
)
invalidate()
}
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (frameTimeData.isEmpty()) {
drawEmptyState(canvas)
return
}
val graphWidth = width - 2 * padding
val graphHeight = height - topPadding - bottomPadding
drawGrid(canvas, graphWidth, graphHeight)
drawTargetLine(canvas, graphWidth, graphHeight) // 60fps target line
drawAverageLine(canvas, graphWidth, graphHeight)
drawGraph(canvas, graphWidth, graphHeight)
drawLegend(canvas)
}
private fun drawEmptyState(canvas: Canvas) {
val message = "No frame time data available"
val textWidth = textPaint.measureText(message)
canvas.drawText(
message,
(width - textWidth) / 2,
height / 2f,
textPaint
)
}
private fun drawGrid(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
// Calculate frame time steps
val step = (maxFrameTime / 4).toInt()
val frameTimeSteps = (0..4).map { it * step }
for (frameTime in frameTimeSteps) {
val y = (topPadding + graphHeight * (1 - frameTime / maxFrameTime)).toFloat()
canvas.drawLine(padding, y, padding + graphWidth, y, gridPaint)
val label = "${frameTime}ms"
canvas.drawText(label, padding - 70f, y + 10f, textPaint)
}
// Y-axis label
canvas.save()
canvas.rotate(-90f, 15f, height / 2f)
val yLabel = "Frame Time (ms)"
val yLabelWidth = textPaint.measureText(yLabel)
canvas.drawText(yLabel, (width - yLabelWidth) / 2, 30f, textPaint)
canvas.restore()
// Time labels on X-axis
if (frameTimeData.isNotEmpty()) {
val startTime = frameTimeData.first().first
val endTime = frameTimeData.last().first
val duration = endTime - startTime
canvas.drawText("0s", padding, height - bottomPadding + 25f, textPaint)
val middleTime = formatDuration(duration / 2)
val middleX = padding + graphWidth / 2
canvas.drawText(middleTime, middleX - 30f, height - bottomPadding + 25f, textPaint)
val endTimeStr = formatDuration(duration)
val endX = padding + graphWidth - textPaint.measureText(endTimeStr)
canvas.drawText(endTimeStr, endX, height - bottomPadding + 25f, textPaint)
}
val xLabel = "Time"
val xLabelWidth = textPaint.measureText(xLabel)
canvas.drawText(xLabel, (width - xLabelWidth) / 2, height - bottomPadding + 55f, textPaint)
}
private fun drawTargetLine(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
// 16.67ms = 60fps target
val targetFrameTime = 16.67
if (targetFrameTime <= maxFrameTime) {
val y = (topPadding + graphHeight * (1 - targetFrameTime / maxFrameTime)).toFloat()
canvas.drawLine(padding, y, padding + graphWidth, y, targetLinePaint)
}
}
private fun drawAverageLine(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
val y = (topPadding + graphHeight * (1 - avgFrameTime / maxFrameTime)).toFloat()
canvas.drawLine(padding, y, padding + graphWidth, y, avgLinePaint)
}
private fun drawGraph(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
if (frameTimeData.size < 2) return
val startTime = frameTimeData.first().first
val endTime = frameTimeData.last().first
val timeDuration = max(endTime - startTime, 1L)
val path = Path()
val fillPath = Path()
frameTimeData.forEachIndexed { index, (timestamp, frameTime) ->
val x = padding + ((timestamp - startTime).toFloat() / timeDuration) * graphWidth
val normalizedFrameTime = max(minFrameTime, min(maxFrameTime, frameTime))
val y = (topPadding + graphHeight * (1 - normalizedFrameTime / maxFrameTime)).toFloat()
if (index == 0) {
path.moveTo(x, y)
fillPath.moveTo(x, height - bottomPadding)
fillPath.lineTo(x, y)
} else {
path.lineTo(x, y)
fillPath.lineTo(x, y)
}
}
val lastX = padding + graphWidth
fillPath.lineTo(lastX, height - bottomPadding)
fillPath.close()
canvas.drawPath(fillPath, fillPaint)
canvas.drawPath(path, linePaint)
}
private fun drawLegend(canvas: Canvas) {
val legendX = padding
val legendY = 20f
val lineLength = 40f
val spacing = 150f
// Frame Time line
canvas.drawLine(legendX, legendY, legendX + lineLength, legendY, linePaint)
canvas.drawText("Frame Time", legendX + lineLength + 10f, legendY + 8f, textPaint.apply { textSize = 24f })
// Average line
canvas.drawLine(legendX + spacing * 1.5f, legendY, legendX + spacing * 1.5f + lineLength, legendY, avgLinePaint)
canvas.drawText("Avg", legendX + spacing * 1.5f + lineLength + 10f, legendY + 8f, textPaint)
// 60fps target line
canvas.drawLine(legendX + spacing * 2.5f, legendY, legendX + spacing * 2.5f + lineLength, legendY, targetLinePaint)
canvas.drawText("60fps", legendX + spacing * 2.5f + lineLength + 10f, legendY + 8f, textPaint)
// Reset text size
textPaint.textSize = 28f
}
private fun formatDuration(durationMs: Long): String {
val seconds = durationMs / 1000
val minutes = seconds / 60
val hours = minutes / 60
return when {
hours > 0 -> String.format("%dh%dm", hours, minutes % 60)
minutes > 0 -> String.format("%dm%ds", minutes, seconds % 60)
else -> String.format("%ds", seconds)
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredWidth = 800
val desiredHeight = 600
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
val width = when (widthMode) {
MeasureSpec.EXACTLY -> widthSize
MeasureSpec.AT_MOST -> min(desiredWidth, widthSize)
else -> desiredWidth
}
val height = when (heightMode) {
MeasureSpec.EXACTLY -> heightSize
MeasureSpec.AT_MOST -> min(desiredHeight, heightSize)
else -> desiredHeight
}
setMeasuredDimension(width, height)
}
}

View File

@@ -0,0 +1,981 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.Color
import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.TypedValue
import android.view.GestureDetector
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.preference.PreferenceManager
import com.android.gamebar.R
import java.io.BufferedReader
import java.io.FileReader
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.*
class GameBar private constructor(context: Context) {
companion object {
@Volatile
private var sInstance: GameBar? = null
@JvmStatic
@Synchronized
fun getInstance(context: Context): GameBar {
return sInstance ?: synchronized(this) {
sInstance ?: GameBar(context.applicationContext).also { sInstance = it }
}
}
@JvmStatic
@Synchronized
fun destroyInstance() {
sInstance?.cleanup()
sInstance = null
}
@JvmStatic
fun isInstanceCreated(): Boolean {
return sInstance != null
}
@JvmStatic
fun isShowing(): Boolean {
return sInstance?.isShowing == true
}
private const val FPS_PATH = "/sys/class/drm/sde-crtc-0/measured_fps"
private const val BATTERY_TEMP_PATH = "/sys/class/power_supply/battery/temp"
private const val PREF_KEY_X = "game_bar_x"
private const val PREF_KEY_Y = "game_bar_y"
private const val TOUCH_SLOP = 30f
}
private val context: Context = context.applicationContext
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val handler: Handler = Handler(Looper.getMainLooper())
private var overlayView: View? = null
private var rootLayout: LinearLayout? = null
private var layoutParams: WindowManager.LayoutParams? = null
@Volatile
private var isShowing = false
// Style properties
private var textSizeSp = 14
private var backgroundAlpha = 128
private var cornerRadius = 90
private var paddingDp = 8
private var titleColorHex = "#FFFFFF"
private var valueColorHex = "#FFFFFF"
private var overlayFormat = "full"
private var position = "top_center"
private var splitMode = "side_by_side"
private var updateIntervalMs = 1000
private var draggable = false
// Display toggles
private var showBatteryTemp = false
private var showCpuUsage = true
private var showCpuClock = false
private var showCpuTemp = false
private var showRam = false
private var showFps = true
private var showFrameTime = false
private var showGpuUsage = true
private var showGpuClock = false
private var showGpuTemp = false
private var showRamSpeed = false
private var showRamTemp = false
// Touch handling
private var longPressEnabled = false
private var longPressThresholdMs = 500L
private var pressActive = false
private var downX = 0f
private var downY = 0f
private var gestureDetector: GestureDetector? = null
private var doubleTapCaptureEnabled = true
private var singleTapToggleEnabled = true
private var bgDrawable: GradientDrawable? = null
private var itemSpacingDp = 8
private var layoutChanged = false
// Touch coordinates for dragging
private var initialX = 0
private var initialY = 0
private var initialTouchX = 0f
private var initialTouchY = 0f
init {
bgDrawable = GradientDrawable()
applyBackgroundStyle()
gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onDoubleTap(e: MotionEvent): Boolean {
if (doubleTapCaptureEnabled) {
val dataExport = GameDataExport.getInstance()
val perAppLogManager = dataExport.getPerAppLogManager()
val currentPackage = ForegroundAppDetector.getForegroundPackageName(context)
if (dataExport.getLoggingMode() == GameDataExport.LoggingMode.PER_APP) {
// Per-app mode: Handle double-tap for manual logging
// Check if this app already has auto-logging enabled
if (perAppLogManager.isAppLoggingEnabled(context, currentPackage)) {
Toast.makeText(context, "This app has auto-logging enabled. Logs are saved automatically.", Toast.LENGTH_SHORT).show()
return true
}
// Check if manually logging for this app
if (perAppLogManager.isAppLoggingActive(currentPackage)) {
// Stop manual logging
perAppLogManager.stopManualLoggingForApp(currentPackage)
Toast.makeText(context, "Manual logging stopped and saved", Toast.LENGTH_SHORT).show()
} else {
// Start manual logging
perAppLogManager.startManualLoggingForApp(currentPackage)
}
return true
}
// Global mode: Original behavior
if (dataExport.isCapturing()) {
dataExport.stopCapture()
dataExport.exportDataToCsv()
Toast.makeText(context, "Capture Stopped and Data Exported", Toast.LENGTH_SHORT).show()
} else {
dataExport.startCapture()
Toast.makeText(context, "Capture Started", Toast.LENGTH_SHORT).show()
}
return true
}
return super.onDoubleTap(e)
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (singleTapToggleEnabled) {
overlayFormat = if (overlayFormat == "full") "minimal" else "full"
PreferenceManager.getDefaultSharedPreferences(context)
.edit()
.putString("game_bar_format", overlayFormat)
.apply()
Toast.makeText(context, "Overlay Format: $overlayFormat", Toast.LENGTH_SHORT).show()
updateStats()
return true
}
return super.onSingleTapConfirmed(e)
}
})
}
// Long press runnable
private val longPressRunnable = Runnable {
if (pressActive) {
openOverlaySettings()
pressActive = false
}
}
// Update runnable
private val updateRunnable = object : Runnable {
override fun run() {
if (isShowing) {
updateStats()
handler.postDelayed(this, updateIntervalMs.toLong())
}
}
}
fun applyPreferences() {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
showFps = prefs.getBoolean("game_bar_fps_enable", true)
showFrameTime = prefs.getBoolean("game_bar_frame_time_enable", false)
showBatteryTemp = prefs.getBoolean("game_bar_temp_enable", false)
showCpuUsage = prefs.getBoolean("game_bar_cpu_usage_enable", true)
showCpuClock = prefs.getBoolean("game_bar_cpu_clock_enable", false)
showCpuTemp = prefs.getBoolean("game_bar_cpu_temp_enable", false)
showRam = prefs.getBoolean("game_bar_ram_enable", false)
showGpuUsage = prefs.getBoolean("game_bar_gpu_usage_enable", true)
showGpuClock = prefs.getBoolean("game_bar_gpu_clock_enable", false)
showGpuTemp = prefs.getBoolean("game_bar_gpu_temp_enable", false)
showRamSpeed = prefs.getBoolean("game_bar_ram_speed_enable", false)
showRamTemp = prefs.getBoolean("game_bar_ram_temp_enable", false)
doubleTapCaptureEnabled = prefs.getBoolean("game_bar_doubletap_capture", true)
singleTapToggleEnabled = prefs.getBoolean("game_bar_single_tap_toggle", true)
updateSplitMode(prefs.getString("game_bar_split_mode", "side_by_side") ?: "side_by_side")
updateTextSize(prefs.getInt("game_bar_text_size", 12))
updateBackgroundAlpha(prefs.getInt("game_bar_background_alpha", 95))
updateCornerRadius(prefs.getInt("game_bar_corner_radius", 100))
updatePadding(prefs.getInt("game_bar_padding", 4))
updateTitleColor(prefs.getString("game_bar_title_color", "#FFFFFF") ?: "#FFFFFF")
updateValueColor(prefs.getString("game_bar_value_color", "#4CAF50") ?: "#4CAF50")
updateOverlayFormat(prefs.getString("game_bar_format", "full") ?: "full")
updateUpdateInterval(prefs.getString("game_bar_update_interval", "1000") ?: "1000")
updatePosition(prefs.getString("game_bar_position", "draggable") ?: "draggable")
val spacing = prefs.getInt("game_bar_item_spacing", 8)
updateItemSpacing(spacing)
longPressEnabled = prefs.getBoolean("game_bar_longpress_enable", true)
val lpTimeoutStr = prefs.getString("game_bar_longpress_timeout", "500") ?: "500"
try {
val lpt = lpTimeoutStr.toLong()
setLongPressThresholdMs(lpt)
} catch (ignored: NumberFormatException) {}
}
fun show() {
// Force cleanup any existing overlay before showing new one
if (isShowing) {
hide()
}
// Double check to make sure no overlay view exists
overlayView?.let { view ->
try {
windowManager.removeView(view)
} catch (e: Exception) {
// View might already be removed
}
overlayView = null
}
applyPreferences()
layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT
)
if ("draggable" == position) {
draggable = true
loadSavedPosition(layoutParams!!)
if (layoutParams!!.x == 0 && layoutParams!!.y == 0) {
layoutParams!!.gravity = Gravity.TOP or Gravity.START
layoutParams!!.x = 0
layoutParams!!.y = 100
}
} else {
draggable = false
applyPosition(layoutParams!!, position)
}
overlayView = LinearLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
rootLayout = overlayView as LinearLayout
applySplitMode()
applyBackgroundStyle()
applyPadding()
overlayView?.setOnTouchListener { _, event ->
gestureDetector?.let {
if (it.onTouchEvent(event)) {
return@setOnTouchListener true
}
}
when (event.action) {
MotionEvent.ACTION_DOWN -> {
if (draggable) {
initialX = layoutParams!!.x
initialY = layoutParams!!.y
initialTouchX = event.rawX
initialTouchY = event.rawY
}
if (longPressEnabled) {
pressActive = true
downX = event.rawX
downY = event.rawY
handler.postDelayed(longPressRunnable, longPressThresholdMs)
}
true
}
MotionEvent.ACTION_MOVE -> {
if (longPressEnabled && pressActive) {
val dx = Math.abs(event.rawX - downX)
val dy = Math.abs(event.rawY - downY)
if (dx > TOUCH_SLOP || dy > TOUCH_SLOP) {
pressActive = false
handler.removeCallbacks(longPressRunnable)
}
}
if (draggable) {
val deltaX = (event.rawX - initialTouchX).toInt()
val deltaY = (event.rawY - initialTouchY).toInt()
layoutParams!!.x = initialX + deltaX
layoutParams!!.y = initialY + deltaY
windowManager.updateViewLayout(overlayView, layoutParams)
}
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (longPressEnabled && pressActive) {
pressActive = false
handler.removeCallbacks(longPressRunnable)
}
if (draggable) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
prefs.edit()
.putInt(PREF_KEY_X, layoutParams!!.x)
.putInt(PREF_KEY_Y, layoutParams!!.y)
.apply()
}
true
}
else -> false
}
}
windowManager.addView(overlayView, layoutParams)
isShowing = true
startUpdates()
// Start the FPS meter if using the new API method
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
GameBarFpsMeter.getInstance(context).start()
}
}
fun hide() {
// Set showing to false first to prevent any further operations
isShowing = false
stopUpdates()
// Stop FPS meter first
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
GameBarFpsMeter.getInstance(context).stop()
}
// Remove overlay view
overlayView?.let { view ->
try {
if (view.parent != null) {
windowManager.removeView(view)
}
} catch (e: Exception) {
// View might already be removed, log but continue cleanup
android.util.Log.w("GameBar", "Error removing overlay view: ${e.message}")
}
}
// Clear all references
overlayView = null
rootLayout = null
layoutParams = null
layoutChanged = true // Mark layout as changed
}
fun cleanup() {
hide()
// Remove all handler callbacks and messages
handler.removeCallbacks(updateRunnable)
handler.removeCallbacks(longPressRunnable)
handler.removeCallbacksAndMessages(null)
// Clear all object references to prevent memory leaks
gestureDetector = null
bgDrawable = null
// Reset all state variables
pressActive = false
layoutChanged = false
}
private fun stopUpdates() {
handler.removeCallbacks(updateRunnable)
handler.removeCallbacks(longPressRunnable)
handler.removeCallbacksAndMessages(null)
}
private fun startUpdates() {
handler.removeCallbacksAndMessages(null)
handler.post(updateRunnable)
}
private fun updateStats() {
// Early return if not showing or no layout
if (!isShowing) return
val layout = rootLayout ?: return
try {
// Always clear views to prevent duplication
layout.removeAllViews()
layoutChanged = false
// Create fresh views each time
val statViews = mutableListOf<View>()
// 1) FPS - Always collect for logging
val fpsVal = GameBarFpsMeter.getInstance(context).getFps()
val fpsStr = if (fpsVal >= 0) String.format(Locale.getDefault(), "%.0f", fpsVal) else "N/A"
if (showFps) {
statViews.add(createStatLine("FPS", fpsStr))
}
// 1.1) Frame Time - Calculate from FPS
var frameTimeStr = "N/A"
if (fpsVal > 0) {
val frameTime = 1000.0 / fpsVal
frameTimeStr = String.format(Locale.getDefault(), "%.2f", frameTime)
}
if (showFrameTime) {
statViews.add(createStatLine("Frame Time", if (frameTimeStr == "N/A") "N/A" else "${frameTimeStr}ms"))
}
// 2) Battery temp - Always collect for logging
var batteryTempStr = "N/A"
val tmp = readLine(BATTERY_TEMP_PATH)
if (!tmp.isNullOrEmpty()) {
try {
val raw = tmp.trim().toInt()
val celsius = raw / 10f
batteryTempStr = String.format(Locale.getDefault(), "%.1f", celsius)
} catch (ignored: NumberFormatException) {}
}
if (showBatteryTemp) {
statViews.add(createStatLine("Temp", "${batteryTempStr}°C"))
}
// 3) CPU usage - Always collect for logging
var cpuUsageStr = "N/A"
cpuUsageStr = GameBarCpuInfo.getCpuUsage()
if (showCpuUsage) {
val display = if (cpuUsageStr == "N/A") "N/A" else "${cpuUsageStr}%"
statViews.add(createStatLine("CPU", display))
}
// 4) CPU freq - Always collect for logging
var cpuClockStr = "N/A"
if (showCpuClock) {
val freqs = GameBarCpuInfo.getCpuFrequencies()
if (freqs.isNotEmpty()) {
statViews.add(buildCpuFreqView(freqs))
cpuClockStr = freqs.joinToString("; ")
}
} else {
// Still collect even if not shown, for potential logging
val freqs = GameBarCpuInfo.getCpuFrequencies()
if (freqs.isNotEmpty()) {
cpuClockStr = freqs.joinToString("; ")
}
}
// 5) CPU temp
var cpuTempStr = "N/A"
if (showCpuTemp) {
cpuTempStr = GameBarCpuInfo.getCpuTemp()
statViews.add(createStatLine("CPU Temp", if (cpuTempStr == "N/A") "N/A" else "${cpuTempStr}°C"))
} else {
// Still collect even if not shown
cpuTempStr = GameBarCpuInfo.getCpuTemp()
}
// 6) RAM usage
var ramStr = "N/A"
if (showRam) {
ramStr = GameBarMemInfo.getRamUsage()
statViews.add(createStatLine("RAM", if (ramStr == "N/A") "N/A" else "$ramStr MB"))
} else {
// Still collect even if not shown
ramStr = GameBarMemInfo.getRamUsage()
}
// 6.1) RAM speed
var ramSpeedStr = "N/A"
if (showRamSpeed) {
ramSpeedStr = GameBarMemInfo.getRamSpeed()
statViews.add(createStatLine("RAM Freq", ramSpeedStr))
} else {
// Still collect even if not shown
ramSpeedStr = GameBarMemInfo.getRamSpeed()
}
// 6.2) RAM temp
var ramTempStr = "N/A"
if (showRamTemp) {
ramTempStr = GameBarMemInfo.getRamTemp()
statViews.add(createStatLine("RAM Temp", ramTempStr))
} else {
// Still collect even if not shown
ramTempStr = GameBarMemInfo.getRamTemp()
}
// 7) GPU usage - Always collect for logging
var gpuUsageStr = "N/A"
gpuUsageStr = GameBarGpuInfo.getGpuUsage()
if (showGpuUsage) {
statViews.add(createStatLine("GPU", if (gpuUsageStr == "N/A") "N/A" else "${gpuUsageStr}%"))
}
// 8) GPU clock - Always collect for logging
var gpuClockStr = "N/A"
gpuClockStr = GameBarGpuInfo.getGpuClock()
if (showGpuClock) {
statViews.add(createStatLine("GPU Freq", if (gpuClockStr == "N/A") "N/A" else "${gpuClockStr}MHz"))
}
// 9) GPU temp - Always collect for logging
var gpuTempStr = "N/A"
gpuTempStr = GameBarGpuInfo.getGpuTemp()
if (showGpuTemp) {
statViews.add(createStatLine("GPU Temp", if (gpuTempStr == "N/A") "N/A" else "${gpuTempStr}°C"))
}
if (splitMode == "side_by_side") {
layout.orientation = LinearLayout.HORIZONTAL
if (overlayFormat == "minimal") {
for (i in statViews.indices) {
layout.addView(statViews[i])
if (i < statViews.size - 1) {
layout.addView(createDotView())
}
}
} else {
for (view in statViews) {
layout.addView(view)
}
}
} else {
layout.orientation = LinearLayout.VERTICAL
for (view in statViews) {
layout.addView(view)
}
}
if (GameDataExport.getInstance().isCapturing()) {
val dateTime = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
val pkgName = ForegroundAppDetector.getForegroundPackageName(context)
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
// Check logging parameters and use N/A if disabled
val logFps = if (prefs.getBoolean(GameBarLogFragment.PREF_LOG_FPS, true)) fpsStr else "N/A"
val logFrameTime = if (prefs.getBoolean(GameBarLogFragment.PREF_LOG_FRAME_TIME, true)) frameTimeStr else "N/A"
val logBatteryTemp = if (prefs.getBoolean(GameBarLogFragment.PREF_LOG_BATTERY_TEMP, true)) batteryTempStr else "N/A"
val logCpuUsage = if (prefs.getBoolean(GameBarLogFragment.PREF_LOG_CPU_USAGE, true)) cpuUsageStr else "N/A"
val logCpuClock = if (prefs.getBoolean(GameBarLogFragment.PREF_LOG_CPU_CLOCK, true)) cpuClockStr else "N/A"
val logCpuTemp = if (prefs.getBoolean(GameBarLogFragment.PREF_LOG_CPU_TEMP, true)) cpuTempStr else "N/A"
val logRam = if (prefs.getBoolean(GameBarLogFragment.PREF_LOG_RAM, true)) ramStr else "N/A"
val logRamSpeed = if (prefs.getBoolean(GameBarLogFragment.PREF_LOG_RAM_SPEED, true)) ramSpeedStr else "N/A"
val logRamTemp = if (prefs.getBoolean(GameBarLogFragment.PREF_LOG_RAM_TEMP, true)) ramTempStr else "N/A"
val logGpuUsage = if (prefs.getBoolean(GameBarLogFragment.PREF_LOG_GPU_USAGE, true)) gpuUsageStr else "N/A"
val logGpuClock = if (prefs.getBoolean(GameBarLogFragment.PREF_LOG_GPU_CLOCK, true)) gpuClockStr else "N/A"
val logGpuTemp = if (prefs.getBoolean(GameBarLogFragment.PREF_LOG_GPU_TEMP, true)) gpuTempStr else "N/A"
GameDataExport.getInstance().addOverlayData(
dateTime,
pkgName,
logFps,
logFrameTime,
logBatteryTemp,
logCpuUsage,
logCpuClock,
logCpuTemp,
logRam,
logRamSpeed,
logRamTemp,
logGpuUsage,
logGpuClock,
logGpuTemp
)
}
layoutParams?.let { lp ->
overlayView?.let { view ->
try {
windowManager.updateViewLayout(view, lp)
} catch (e: Exception) {
// View might be in invalid state, ignore
}
}
}
} catch (e: Exception) {
// Log error but continue operation to prevent crashes
android.util.Log.e("GameBar", "Error updating overlay stats: ${e.message}")
}
}
private fun buildCpuFreqView(freqs: List<String>): View {
val freqContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
}
val spacingPx = dpToPx(context, itemSpacingDp)
val outerLp = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
setMargins(spacingPx, spacingPx / 2, spacingPx, spacingPx / 2)
}
freqContainer.layoutParams = outerLp
if (overlayFormat == "full") {
val labelTv = TextView(context).apply {
setTextSize(TypedValue.COMPLEX_UNIT_SP, textSizeSp.toFloat())
try {
setTextColor(Color.parseColor(titleColorHex))
} catch (e: Exception) {
setTextColor(Color.WHITE)
}
text = "CPU Freq "
}
freqContainer.addView(labelTv)
}
val verticalFreqs = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
}
for (freqLine in freqs) {
val lineLayout = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
}
val freqTv = TextView(context).apply {
setTextSize(TypedValue.COMPLEX_UNIT_SP, textSizeSp.toFloat())
try {
setTextColor(Color.parseColor(valueColorHex))
} catch (e: Exception) {
setTextColor(Color.WHITE)
}
text = freqLine
}
lineLayout.addView(freqTv)
val lineLp = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
setMargins(spacingPx, spacingPx / 4, spacingPx, spacingPx / 4)
}
lineLayout.layoutParams = lineLp
verticalFreqs.addView(lineLayout)
}
freqContainer.addView(verticalFreqs)
return freqContainer
}
private fun createStatLine(title: String, rawValue: String): LinearLayout {
val lineLayout = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
}
if (overlayFormat == "full") {
val tvTitle = TextView(context).apply {
setTextSize(TypedValue.COMPLEX_UNIT_SP, textSizeSp.toFloat())
try {
setTextColor(Color.parseColor(titleColorHex))
} catch (e: Exception) {
setTextColor(Color.WHITE)
}
text = if (title.isEmpty()) "" else "$title "
}
val tvValue = TextView(context).apply {
setTextSize(TypedValue.COMPLEX_UNIT_SP, textSizeSp.toFloat())
try {
setTextColor(Color.parseColor(valueColorHex))
} catch (e: Exception) {
setTextColor(Color.WHITE)
}
text = rawValue
}
lineLayout.addView(tvTitle)
lineLayout.addView(tvValue)
} else {
val tvMinimal = TextView(context).apply {
setTextSize(TypedValue.COMPLEX_UNIT_SP, textSizeSp.toFloat())
try {
setTextColor(Color.parseColor(valueColorHex))
} catch (e: Exception) {
setTextColor(Color.WHITE)
}
text = rawValue
}
lineLayout.addView(tvMinimal)
}
val spacingPx = dpToPx(context, itemSpacingDp)
val lp = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
setMargins(spacingPx, spacingPx / 2, spacingPx, spacingPx / 2)
}
lineLayout.layoutParams = lp
return lineLayout
}
private fun createDotView(): View {
return TextView(context).apply {
setTextSize(TypedValue.COMPLEX_UNIT_SP, textSizeSp.toFloat())
try {
setTextColor(Color.parseColor(valueColorHex))
} catch (e: Exception) {
setTextColor(Color.WHITE)
}
text = " . "
}
}
// Public setter methods for feature toggles
fun setShowBatteryTemp(show: Boolean) { showBatteryTemp = show }
fun setShowCpuUsage(show: Boolean) { showCpuUsage = show }
fun setShowCpuClock(show: Boolean) { showCpuClock = show }
fun setShowCpuTemp(show: Boolean) { showCpuTemp = show }
fun setShowRam(show: Boolean) { showRam = show }
fun setShowFps(show: Boolean) { showFps = show }
fun setShowFrameTime(show: Boolean) { showFrameTime = show }
fun setShowGpuUsage(show: Boolean) { showGpuUsage = show }
fun setShowGpuClock(show: Boolean) { showGpuClock = show }
fun setShowGpuTemp(show: Boolean) { showGpuTemp = show }
fun setShowRamSpeed(show: Boolean) { showRamSpeed = show }
fun setShowRamTemp(show: Boolean) { showRamTemp = show }
fun updateTextSize(sp: Int) {
textSizeSp = sp
}
fun updateCornerRadius(radius: Int) {
cornerRadius = radius
applyBackgroundStyle()
}
fun updateBackgroundAlpha(alpha: Int) {
backgroundAlpha = alpha
applyBackgroundStyle()
}
fun updatePadding(dp: Int) {
paddingDp = dp
applyPadding()
}
fun updateTitleColor(hex: String) {
titleColorHex = hex
}
fun updateValueColor(hex: String) {
valueColorHex = hex
}
fun updateOverlayFormat(format: String) {
overlayFormat = format
if (isShowing) {
updateStats()
}
}
fun updateItemSpacing(dp: Int) {
itemSpacingDp = dp
if (isShowing) {
updateStats()
}
}
fun updatePosition(pos: String) {
position = pos
if (isShowing && overlayView != null && layoutParams != null) {
if ("draggable" == position) {
draggable = true
loadSavedPosition(layoutParams!!)
if (layoutParams!!.x == 0 && layoutParams!!.y == 0) {
layoutParams!!.gravity = Gravity.TOP or Gravity.START
layoutParams!!.x = 0
layoutParams!!.y = 100
}
} else {
draggable = false
applyPosition(layoutParams!!, position)
}
windowManager.updateViewLayout(overlayView, layoutParams)
}
}
fun updateSplitMode(mode: String) {
splitMode = mode
if (isShowing && overlayView != null) {
applySplitMode()
updateStats()
}
}
fun updateUpdateInterval(intervalStr: String) {
try {
updateIntervalMs = intervalStr.toInt()
} catch (e: NumberFormatException) {
updateIntervalMs = 1000
}
if (isShowing) {
startUpdates()
}
}
fun setLongPressEnabled(enabled: Boolean) {
longPressEnabled = enabled
}
fun setLongPressThresholdMs(ms: Long) {
longPressThresholdMs = ms
}
fun setDoubleTapCaptureEnabled(enabled: Boolean) {
doubleTapCaptureEnabled = enabled
}
fun setSingleTapToggleEnabled(enabled: Boolean) {
singleTapToggleEnabled = enabled
}
fun isCurrentlyShowing(): Boolean {
return isShowing
}
private fun applyBackgroundStyle() {
// Ensure we have a valid bgDrawable
if (bgDrawable == null) {
bgDrawable = GradientDrawable()
}
// Apply background color with proper alpha
val color = Color.argb(
Math.max(backgroundAlpha, 16), // Minimum alpha of 16 to prevent invisible overlays
0, 0, 0
)
bgDrawable?.setColor(color)
bgDrawable?.cornerRadius = cornerRadius.toFloat()
// Only apply background if overlay view exists
overlayView?.let { view ->
view.background = bgDrawable
}
}
private fun applyPadding() {
rootLayout?.let {
val px = dpToPx(context, paddingDp)
it.setPadding(px, px, px, px)
}
}
private fun applySplitMode() {
rootLayout?.let {
it.orientation = if (splitMode == "side_by_side") {
LinearLayout.HORIZONTAL
} else {
LinearLayout.VERTICAL
}
}
}
private fun loadSavedPosition(lp: WindowManager.LayoutParams) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val savedX = prefs.getInt(PREF_KEY_X, Int.MIN_VALUE)
val savedY = prefs.getInt(PREF_KEY_Y, Int.MIN_VALUE)
if (savedX != Int.MIN_VALUE && savedY != Int.MIN_VALUE) {
lp.gravity = Gravity.TOP or Gravity.START
lp.x = savedX
lp.y = savedY
}
}
private fun applyPosition(lp: WindowManager.LayoutParams, pos: String) {
when (pos) {
"top_left" -> {
lp.gravity = Gravity.TOP or Gravity.START
lp.x = 0
lp.y = 100
}
"top_center" -> {
lp.gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
lp.y = 100
}
"top_right" -> {
lp.gravity = Gravity.TOP or Gravity.END
lp.x = 0
lp.y = 100
}
"bottom_left" -> {
lp.gravity = Gravity.BOTTOM or Gravity.START
lp.x = 0
lp.y = 100
}
"bottom_center" -> {
lp.gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
lp.y = 100
}
"bottom_right" -> {
lp.gravity = Gravity.BOTTOM or Gravity.END
lp.x = 0
lp.y = 100
}
else -> {
lp.gravity = Gravity.TOP or Gravity.START
lp.x = 0
lp.y = 100
}
}
}
private fun readLine(path: String): String? {
return try {
BufferedReader(FileReader(path)).use { it.readLine() }
} catch (e: IOException) {
null
}
}
private fun openOverlaySettings() {
try {
val intent = Intent(context, GameBarSettingsActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} catch (e: Exception) {
// Exception ignored
}
}
private fun dpToPx(context: Context, dp: Int): Int {
val scale = context.resources.displayMetrics.density
return Math.round(dp * scale)
}
}

View File

@@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.preference.PreferenceManager
class GameBarBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (Intent.ACTION_BOOT_COMPLETED == action || Intent.ACTION_LOCKED_BOOT_COMPLETED == action) {
restoreOverlayState(context)
}
}
private fun restoreOverlayState(context: Context) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val mainEnabled = prefs.getBoolean("game_bar_enable", false)
val autoEnabled = prefs.getBoolean("game_bar_auto_enable", false)
if (mainEnabled) {
val gameBar = GameBar.getInstance(context)
gameBar.applyPreferences()
gameBar.show()
}
if (autoEnabled) {
val monitorIntent = Intent(context, GameBarMonitorService::class.java)
context.startService(monitorIntent)
}
}
}

View File

@@ -0,0 +1,121 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import java.io.BufferedReader
import java.io.FileReader
import java.io.IOException
import java.io.File
import java.util.*
object GameBarCpuInfo {
private var prevIdle = -1L
private var prevTotal = -1L
private const val CPU_TEMP_PATH = "/sys/class/thermal/thermal_zone48/temp"
fun getCpuUsage(): String {
val line = readLine("/proc/stat")
if (line == null || !line.startsWith("cpu ")) return "N/A"
val parts = line.split("\\s+".toRegex())
if (parts.size < 8) return "N/A"
return try {
val user = parts[1].toLong()
val nice = parts[2].toLong()
val system = parts[3].toLong()
val idle = parts[4].toLong()
val iowait = parts[5].toLong()
val irq = parts[6].toLong()
val softirq = parts[7].toLong()
val steal = if (parts.size > 8) parts[8].toLong() else 0L
val total = user + nice + system + idle + iowait + irq + softirq + steal
if (prevTotal != -1L && total != prevTotal) {
val diffTotal = total - prevTotal
val diffIdle = idle - prevIdle
val usage = 100 * (diffTotal - diffIdle) / diffTotal
prevTotal = total
prevIdle = idle
usage.toString()
} else {
prevTotal = total
prevIdle = idle
"N/A"
}
} catch (e: NumberFormatException) {
"N/A"
}
}
fun getCpuFrequencies(): List<String> {
val result = mutableListOf<String>()
val cpuDirPath = "/sys/devices/system/cpu/"
val cpuDir = File(cpuDirPath)
val files = cpuDir.listFiles { _, name -> name.matches(Regex("cpu\\d+")) }
if (files.isNullOrEmpty()) {
return result
}
val cpuFolders = files.toMutableList()
cpuFolders.sortBy { extractCpuNumber(it) }
for (cpu in cpuFolders) {
val freqPath = "${cpu.absolutePath}/cpufreq/scaling_cur_freq"
val freqStr = readLine(freqPath)
if (!freqStr.isNullOrEmpty()) {
try {
val khz = freqStr.trim().toInt()
val mhz = khz / 1000
result.add("${cpu.name}: $mhz MHz")
} catch (e: NumberFormatException) {
result.add("${cpu.name}: N/A")
}
} else {
result.add("${cpu.name}: offline or frequency not available")
}
}
return result
}
fun getCpuTemp(): String {
val line = readLine(CPU_TEMP_PATH) ?: return "N/A"
val cleanLine = line.trim()
return try {
val raw = cleanLine.toFloat()
// Device reports in millidegrees (e.g., 33849 = 33.849°C)
val celsius = raw / 1000f
// Sanity check: CPU temp should be between 0 and 150°C
if (celsius > 0f && celsius < 150f) {
String.format(Locale.getDefault(), "%.1f", celsius)
} else {
"N/A"
}
} catch (e: NumberFormatException) {
"N/A"
}
}
private fun extractCpuNumber(cpuFolder: File): Int {
val name = cpuFolder.name.replace("cpu", "")
return try {
name.toInt()
} catch (e: NumberFormatException) {
-1
}
}
private fun readLine(path: String): String? {
return try {
BufferedReader(FileReader(path)).use { it.readLine() }
} catch (e: IOException) {
null
}
}
}

View File

@@ -0,0 +1,186 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.os.Handler
import android.view.WindowManager
import android.window.TaskFpsCallback
import androidx.preference.PreferenceManager
import java.io.BufferedReader
import java.io.FileReader
import java.io.IOException
import java.lang.reflect.Field
import java.lang.reflect.Method
class GameBarFpsMeter private constructor(context: Context) {
companion object {
private const val TOLERANCE = 0.1f
private const val STALENESS_THRESHOLD_MS = 2000L
private const val TASK_CHECK_INTERVAL_MS = 1000L
@Volatile
private var INSTANCE: GameBarFpsMeter? = null
@JvmStatic
@Synchronized
fun getInstance(context: Context): GameBarFpsMeter {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: GameBarFpsMeter(context.applicationContext).also { INSTANCE = it }
}
}
}
private val context: Context = context.applicationContext
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
private var currentFps = 0f
private var taskFpsCallback: TaskFpsCallback? = null
private var callbackRegistered = false
private var currentTaskId = -1
private var lastFpsUpdateTime = System.currentTimeMillis()
private val handler = Handler()
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
taskFpsCallback = object : TaskFpsCallback() {
override fun onFpsReported(fps: Float) {
if (fps > 0) {
currentFps = fps
lastFpsUpdateTime = System.currentTimeMillis()
}
}
}
}
}
fun start() {
val method = prefs.getString("game_bar_fps_method", "new")
if (method != "new") return
stop()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val taskId = getFocusedTaskId()
if (taskId <= 0) {
return
}
currentTaskId = taskId
try {
taskFpsCallback?.let {
windowManager.registerTaskFpsCallback(currentTaskId, Runnable::run, it)
callbackRegistered = true
}
} catch (e: Exception) {
// Ignore registration errors
}
lastFpsUpdateTime = System.currentTimeMillis()
handler.postDelayed(taskCheckRunnable, TASK_CHECK_INTERVAL_MS)
}
}
fun stop() {
val method = prefs.getString("game_bar_fps_method", "new")
if (method == "new" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (callbackRegistered) {
try {
taskFpsCallback?.let {
windowManager.unregisterTaskFpsCallback(it)
}
} catch (e: Exception) {
// Ignore unregistration errors
}
callbackRegistered = false
}
handler.removeCallbacks(taskCheckRunnable)
}
}
fun getFps(): Float {
val method = prefs.getString("game_bar_fps_method", "new")
return if (method == "legacy") {
readLegacyFps()
} else {
currentFps
}
}
private fun readLegacyFps(): Float {
try {
BufferedReader(FileReader("/sys/class/drm/sde-crtc-0/measured_fps")).use { br ->
val line = br.readLine()
if (line != null && line.startsWith("fps:")) {
val parts = line.split("\\s+".toRegex())
if (parts.size >= 2) {
return parts[1].trim().toFloat()
}
}
}
} catch (e: IOException) {
// Ignore errors
} catch (e: NumberFormatException) {
// Ignore errors
}
return -1f
}
private val taskCheckRunnable = object : Runnable {
override fun run() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val newTaskId = getFocusedTaskId()
if (newTaskId > 0 && newTaskId != currentTaskId) {
reinitCallback()
} else {
val now = System.currentTimeMillis()
if (now - lastFpsUpdateTime > STALENESS_THRESHOLD_MS) {
reinitCallback()
}
}
handler.postDelayed(this, TASK_CHECK_INTERVAL_MS)
}
}
}
private fun getFocusedTaskId(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return -1
}
try {
val atmClass = Class.forName("android.app.ActivityTaskManager")
val getServiceMethod = atmClass.getDeclaredMethod("getService")
val atmService = getServiceMethod.invoke(null)
val getFocusedRootTaskInfoMethod = atmService.javaClass.getMethod("getFocusedRootTaskInfo")
val taskInfo = getFocusedRootTaskInfoMethod.invoke(atmService)
if (taskInfo != null) {
try {
val taskIdField = taskInfo.javaClass.getField("taskId")
return taskIdField.getInt(taskInfo)
} catch (nsfe: NoSuchFieldException) {
try {
val taskIdField = taskInfo.javaClass.getField("mTaskId")
return taskIdField.getInt(taskInfo)
} catch (nsfe2: NoSuchFieldException) {
// Both field names failed
}
}
}
} catch (e: Exception) {
// Ignore reflection errors
}
return -1
}
private fun reinitCallback() {
stop()
handler.postDelayed({
start()
}, 500)
}
}

View File

@@ -0,0 +1,354 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import android.widget.Toast
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import com.android.gamebar.utils.PartsCustomSeekBarPreference
import androidx.preference.SwitchPreferenceCompat
import com.android.settingslib.widget.MainSwitchPreference
import com.android.gamebar.R
class GameBarFragment : PreferenceFragmentCompat() {
private var gameBar: GameBar? = null
private var masterSwitch: MainSwitchPreference? = null
private var autoEnableSwitch: SwitchPreferenceCompat? = null
private var fpsSwitch: SwitchPreferenceCompat? = null
private var frameTimeSwitch: SwitchPreferenceCompat? = null
private var batteryTempSwitch: SwitchPreferenceCompat? = null
private var cpuUsageSwitch: SwitchPreferenceCompat? = null
private var cpuClockSwitch: SwitchPreferenceCompat? = null
private var cpuTempSwitch: SwitchPreferenceCompat? = null
private var ramSwitch: SwitchPreferenceCompat? = null
private var gpuUsageSwitch: SwitchPreferenceCompat? = null
private var gpuClockSwitch: SwitchPreferenceCompat? = null
private var gpuTempSwitch: SwitchPreferenceCompat? = null
private var doubleTapCapturePref: SwitchPreferenceCompat? = null
private var singleTapTogglePref: SwitchPreferenceCompat? = null
private var longPressEnablePref: SwitchPreferenceCompat? = null
private var longPressTimeoutPref: ListPreference? = null
private var textSizePref: PartsCustomSeekBarPreference? = null
private var bgAlphaPref: PartsCustomSeekBarPreference? = null
private var cornerRadiusPref: PartsCustomSeekBarPreference? = null
private var paddingPref: PartsCustomSeekBarPreference? = null
private var itemSpacingPref: PartsCustomSeekBarPreference? = null
private var updateIntervalPref: ListPreference? = null
private var textColorPref: ListPreference? = null
private var titleColorPref: ListPreference? = null
private var valueColorPref: ListPreference? = null
private var positionPref: ListPreference? = null
private var splitModePref: ListPreference? = null
private var overlayFormatPref: ListPreference? = null
private var ramSpeedSwitch: SwitchPreferenceCompat? = null
private var ramTempSwitch: SwitchPreferenceCompat? = null
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.game_bar_preferences, rootKey)
gameBar = GameBar.getInstance(requireContext())
// Initialize all preferences
masterSwitch = findPreference("game_bar_enable")
autoEnableSwitch = findPreference("game_bar_auto_enable")
fpsSwitch = findPreference("game_bar_fps_enable")
frameTimeSwitch = findPreference("game_bar_frame_time_enable")
batteryTempSwitch = findPreference("game_bar_temp_enable")
cpuUsageSwitch = findPreference("game_bar_cpu_usage_enable")
cpuClockSwitch = findPreference("game_bar_cpu_clock_enable")
cpuTempSwitch = findPreference("game_bar_cpu_temp_enable")
ramSwitch = findPreference("game_bar_ram_enable")
gpuUsageSwitch = findPreference("game_bar_gpu_usage_enable")
gpuClockSwitch = findPreference("game_bar_gpu_clock_enable")
gpuTempSwitch = findPreference("game_bar_gpu_temp_enable")
ramSpeedSwitch = findPreference("game_bar_ram_speed_enable")
ramTempSwitch = findPreference("game_bar_ram_temp_enable")
doubleTapCapturePref = findPreference("game_bar_doubletap_capture")
singleTapTogglePref = findPreference("game_bar_single_tap_toggle")
longPressEnablePref = findPreference("game_bar_longpress_enable")
longPressTimeoutPref = findPreference("game_bar_longpress_timeout")
textSizePref = findPreference("game_bar_text_size")
bgAlphaPref = findPreference("game_bar_background_alpha")
cornerRadiusPref = findPreference("game_bar_corner_radius")
paddingPref = findPreference("game_bar_padding")
itemSpacingPref = findPreference("game_bar_item_spacing")
updateIntervalPref = findPreference("game_bar_update_interval")
textColorPref = findPreference("game_bar_text_color")
titleColorPref = findPreference("game_bar_title_color")
valueColorPref = findPreference("game_bar_value_color")
positionPref = findPreference("game_bar_position")
splitModePref = findPreference("game_bar_split_mode")
overlayFormatPref = findPreference("game_bar_format")
setupPerAppConfig()
setupMasterSwitchListener()
setupAutoEnableSwitchListener()
setupFeatureSwitchListeners()
setupGesturePrefListeners()
setupStylePrefListeners()
setupExpandableCategories()
}
private fun setupExpandableCategories() {
// Get all expandable categories by their keys
val overlayFeaturesCategory: ExpandablePreferenceCategory? = findPreference("category_overlay_features")
val fpsMethodCategory: ExpandablePreferenceCategory? = findPreference("category_fps_method")
val customizationCategory: ExpandablePreferenceCategory? = findPreference("category_customization")
val splitConfigCategory: ExpandablePreferenceCategory? = findPreference("category_split_config")
val gestureControlsCategory: ExpandablePreferenceCategory? = findPreference("category_gesture_controls")
val perAppCategory: ExpandablePreferenceCategory? = findPreference("category_per_app")
// Categories are collapsed by default
// You can expand some by default if needed, e.g.:
// overlayFeaturesCategory?.setExpanded(true)
}
private fun setupPerAppConfig() {
val perAppConfigPref: Preference? = findPreference("game_bar_per_app_config")
perAppConfigPref?.setOnPreferenceClickListener {
startActivity(Intent(requireContext(), GameBarPerAppConfigActivity::class.java))
true
}
}
private fun setupMasterSwitchListener() {
masterSwitch?.setOnPreferenceChangeListener { _, newValue ->
val enabled = newValue as Boolean
if (enabled) {
if (Settings.canDrawOverlays(requireContext())) {
android.util.Log.d("GameBarFragment", "Enabling GameBar from settings")
// Ensure we have a fresh GameBar instance
if (!GameBar.isInstanceCreated()) {
gameBar = GameBar.getInstance(requireContext())
}
// Clean up any existing instance before creating new one
gameBar?.hide()
gameBar?.applyPreferences()
gameBar?.show()
requireContext().startService(Intent(requireContext(), GameBarMonitorService::class.java))
true
} else {
Toast.makeText(requireContext(), R.string.overlay_permission_required, Toast.LENGTH_SHORT).show()
false
}
} else {
android.util.Log.d("GameBarFragment", "Disabling GameBar from settings")
gameBar?.hide()
// Destroy singleton to ensure clean state
GameBar.destroyInstance()
// Only stop monitor service if auto-enable is also disabled
if (autoEnableSwitch?.isChecked != true) {
requireContext().stopService(Intent(requireContext(), GameBarMonitorService::class.java))
}
true
}
}
}
private fun setupAutoEnableSwitchListener() {
autoEnableSwitch?.setOnPreferenceChangeListener { _, newValue ->
val autoEnabled = newValue as Boolean
if (autoEnabled) {
requireContext().startService(Intent(requireContext(), GameBarMonitorService::class.java))
} else {
if (masterSwitch?.isChecked != true) {
requireContext().stopService(Intent(requireContext(), GameBarMonitorService::class.java))
}
}
true
}
}
private fun setupFeatureSwitchListeners() {
fpsSwitch?.setOnPreferenceChangeListener { _, newValue ->
gameBar?.setShowFps(newValue as Boolean)
true
}
frameTimeSwitch?.setOnPreferenceChangeListener { _, newValue ->
gameBar?.setShowFrameTime(newValue as Boolean)
true
}
batteryTempSwitch?.setOnPreferenceChangeListener { _, newValue ->
gameBar?.setShowBatteryTemp(newValue as Boolean)
true
}
cpuUsageSwitch?.setOnPreferenceChangeListener { _, newValue ->
gameBar?.setShowCpuUsage(newValue as Boolean)
true
}
cpuClockSwitch?.setOnPreferenceChangeListener { _, newValue ->
gameBar?.setShowCpuClock(newValue as Boolean)
true
}
cpuTempSwitch?.setOnPreferenceChangeListener { _, newValue ->
gameBar?.setShowCpuTemp(newValue as Boolean)
true
}
ramSwitch?.setOnPreferenceChangeListener { _, newValue ->
gameBar?.setShowRam(newValue as Boolean)
true
}
gpuUsageSwitch?.setOnPreferenceChangeListener { _, newValue ->
gameBar?.setShowGpuUsage(newValue as Boolean)
true
}
gpuClockSwitch?.setOnPreferenceChangeListener { _, newValue ->
gameBar?.setShowGpuClock(newValue as Boolean)
true
}
gpuTempSwitch?.setOnPreferenceChangeListener { _, newValue ->
gameBar?.setShowGpuTemp(newValue as Boolean)
true
}
ramSpeedSwitch?.setOnPreferenceChangeListener { _, newValue ->
gameBar?.setShowRamSpeed(newValue as Boolean)
true
}
ramTempSwitch?.setOnPreferenceChangeListener { _, newValue ->
gameBar?.setShowRamTemp(newValue as Boolean)
true
}
}
private fun setupGesturePrefListeners() {
doubleTapCapturePref?.setOnPreferenceChangeListener { _, newValue ->
gameBar?.setDoubleTapCaptureEnabled(newValue as Boolean)
true
}
singleTapTogglePref?.setOnPreferenceChangeListener { _, newValue ->
gameBar?.setSingleTapToggleEnabled(newValue as Boolean)
true
}
longPressEnablePref?.setOnPreferenceChangeListener { _, newValue ->
gameBar?.setLongPressEnabled(newValue as Boolean)
true
}
longPressTimeoutPref?.setOnPreferenceChangeListener { _, newValue ->
if (newValue is String) {
val ms = newValue.toLong()
gameBar?.setLongPressThresholdMs(ms)
}
true
}
}
private fun setupStylePrefListeners() {
textSizePref?.setOnPreferenceChangeListener { _, newValue ->
if (newValue is Int) {
gameBar?.updateTextSize(newValue)
}
true
}
bgAlphaPref?.setOnPreferenceChangeListener { _, newValue ->
if (newValue is Int) {
gameBar?.updateBackgroundAlpha(newValue)
}
true
}
cornerRadiusPref?.setOnPreferenceChangeListener { _, newValue ->
if (newValue is Int) {
gameBar?.updateCornerRadius(newValue)
}
true
}
paddingPref?.setOnPreferenceChangeListener { _, newValue ->
if (newValue is Int) {
gameBar?.updatePadding(newValue)
}
true
}
itemSpacingPref?.setOnPreferenceChangeListener { _, newValue ->
if (newValue is Int) {
gameBar?.updateItemSpacing(newValue)
}
true
}
updateIntervalPref?.setOnPreferenceChangeListener { _, newValue ->
if (newValue is String) {
gameBar?.updateUpdateInterval(newValue)
}
true
}
textColorPref?.setOnPreferenceChangeListener { _, _ ->
true
}
titleColorPref?.setOnPreferenceChangeListener { _, newValue ->
if (newValue is String) {
gameBar?.updateTitleColor(newValue)
}
true
}
valueColorPref?.setOnPreferenceChangeListener { _, newValue ->
if (newValue is String) {
gameBar?.updateValueColor(newValue)
}
true
}
positionPref?.setOnPreferenceChangeListener { _, newValue ->
if (newValue is String) {
gameBar?.updatePosition(newValue)
}
true
}
splitModePref?.setOnPreferenceChangeListener { _, newValue ->
if (newValue is String) {
gameBar?.updateSplitMode(newValue)
}
true
}
overlayFormatPref?.setOnPreferenceChangeListener { _, newValue ->
if (newValue is String) {
gameBar?.updateOverlayFormat(newValue)
}
true
}
}
override fun onResume() {
super.onResume()
if (!hasUsageStatsPermission(requireContext())) {
requestUsageStatsPermission()
}
requireContext().let { context ->
if ((masterSwitch?.isChecked == true) || (autoEnableSwitch?.isChecked == true)) {
context.startService(Intent(context, GameBarMonitorService::class.java))
} else {
context.stopService(Intent(context, GameBarMonitorService::class.java))
}
}
}
private fun hasUsageStatsPermission(context: Context): Boolean {
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as? android.app.AppOpsManager
?: return false
val mode = appOps.checkOpNoThrow(
android.app.AppOpsManager.OPSTR_GET_USAGE_STATS,
android.os.Process.myUid(),
context.packageName
)
return mode == android.app.AppOpsManager.MODE_ALLOWED
}
private fun requestUsageStatsPermission() {
val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)
startActivity(intent)
}
}

View File

@@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import java.io.BufferedReader
import java.io.FileReader
import java.io.IOException
object GameBarGpuInfo {
private const val GPU_USAGE_PATH = "/sys/class/kgsl/kgsl-3d0/gpu_busy_percentage"
private const val GPU_CLOCK_PATH = "/sys/class/kgsl/kgsl-3d0/gpuclk"
private const val GPU_TEMP_PATH = "/sys/class/kgsl/kgsl-3d0/temp"
fun getGpuUsage(): String {
val line = readLine(GPU_USAGE_PATH) ?: return "N/A"
val cleanLine = line.replace("%", "").trim()
return try {
val value = cleanLine.toInt()
value.toString()
} catch (e: NumberFormatException) {
"N/A"
}
}
fun getGpuClock(): String {
val line = readLine(GPU_CLOCK_PATH) ?: return "N/A"
val cleanLine = line.trim()
return try {
val hz = cleanLine.toLong()
val mhz = hz / 1_000_000
mhz.toString()
} catch (e: NumberFormatException) {
"N/A"
}
}
fun getGpuTemp(): String {
val line = readLine(GPU_TEMP_PATH) ?: return "N/A"
val cleanLine = line.trim()
return try {
val raw = cleanLine.toFloat()
val celsius = raw / 1000f
String.format("%.1f", celsius)
} catch (e: NumberFormatException) {
"N/A"
}
}
private fun readLine(path: String): String? {
return try {
BufferedReader(FileReader(path)).use { it.readLine() }
} catch (e: IOException) {
null
}
}
}

View File

@@ -0,0 +1,98 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.app.AlertDialog
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.CheckBox
import android.widget.LinearLayout
import androidx.preference.PreferenceManager
import com.android.settingslib.collapsingtoolbar.CollapsingToolbarBaseActivity
import com.android.gamebar.R
class GameBarLogActivity : CollapsingToolbarBaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_gamebar_log)
title = "GameBar Log Monitor"
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(R.id.content_frame, GameBarLogFragment())
.commit()
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.gamebar_log_menu, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_logging_parameters -> {
showLoggingParametersDialog()
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun showLoggingParametersDialog() {
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
// Create checkboxes for each parameter
val container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(48, 24, 48, 24)
}
val checkboxes = mutableMapOf<String, CheckBox>()
val parameters = listOf(
Pair(GameBarLogFragment.PREF_LOG_FPS, "FPS"),
Pair(GameBarLogFragment.PREF_LOG_FRAME_TIME, "Frame Time"),
Pair(GameBarLogFragment.PREF_LOG_BATTERY_TEMP, "Battery Temperature"),
Pair(GameBarLogFragment.PREF_LOG_CPU_USAGE, "CPU Usage"),
Pair(GameBarLogFragment.PREF_LOG_CPU_CLOCK, "CPU Clock"),
Pair(GameBarLogFragment.PREF_LOG_CPU_TEMP, "CPU Temperature"),
Pair(GameBarLogFragment.PREF_LOG_RAM, "RAM Usage"),
Pair(GameBarLogFragment.PREF_LOG_RAM_SPEED, "RAM Frequency"),
Pair(GameBarLogFragment.PREF_LOG_RAM_TEMP, "RAM Temperature"),
Pair(GameBarLogFragment.PREF_LOG_GPU_USAGE, "GPU Usage"),
Pair(GameBarLogFragment.PREF_LOG_GPU_CLOCK, "GPU Clock"),
Pair(GameBarLogFragment.PREF_LOG_GPU_TEMP, "GPU Temperature")
)
parameters.forEach { (key, label) ->
val cb = CheckBox(this).apply {
text = label
isChecked = prefs.getBoolean(key, true)
setPadding(16, 16, 16, 16)
}
checkboxes[key] = cb
container.addView(cb)
}
AlertDialog.Builder(this)
.setTitle("Logging Parameters")
.setMessage("Select which parameters to include in logs:")
.setView(container)
.setPositiveButton("Apply") { _, _ ->
val editor = prefs.edit()
checkboxes.forEach { (key, checkbox) ->
editor.putBoolean(key, checkbox.isChecked)
}
editor.apply()
}
.setNegativeButton("Cancel", null)
.show()
}
}

View File

@@ -0,0 +1,644 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.app.AlertDialog
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText
import android.widget.PopupMenu
import android.widget.RadioButton
import android.widget.RadioGroup
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.android.gamebar.R
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
class GameBarLogFragment : Fragment(), GameDataExport.CaptureStateListener, PerAppLogManager.PerAppStateListener {
private lateinit var searchBar: EditText
private lateinit var startCaptureButton: Button
private lateinit var stopCaptureButton: Button
private lateinit var manualLogsButton: Button
private lateinit var logTypeRadioGroup: RadioGroup
private lateinit var globalLoggingRadio: RadioButton
private lateinit var perAppLoggingRadio: RadioButton
private lateinit var logHistoryRecyclerView: RecyclerView
private lateinit var logHistoryAdapter: LogHistoryAdapter
private lateinit var perAppLogAdapter: PerAppLogAdapter
private val gameDataExport = GameDataExport.getInstance()
private val perAppLogManager = PerAppLogManager.getInstance()
private val logFiles = mutableListOf<LogFile>()
private val allLogFiles = mutableListOf<LogFile>()
private val installedApps = mutableListOf<ApplicationInfo>()
private val filteredApps = mutableListOf<ApplicationInfo>()
private var currentLoggingMode = GameDataExport.LoggingMode.GLOBAL
companion object {
private const val PREF_LOGGING_MODE = "gamebar_logging_mode"
// Logging parameter preference keys
const val PREF_LOG_FPS = "gamebar_log_fps"
const val PREF_LOG_FRAME_TIME = "gamebar_log_frame_time"
const val PREF_LOG_BATTERY_TEMP = "gamebar_log_battery_temp"
const val PREF_LOG_CPU_USAGE = "gamebar_log_cpu_usage"
const val PREF_LOG_CPU_CLOCK = "gamebar_log_cpu_clock"
const val PREF_LOG_CPU_TEMP = "gamebar_log_cpu_temp"
const val PREF_LOG_RAM = "gamebar_log_ram"
const val PREF_LOG_RAM_SPEED = "gamebar_log_ram_speed"
const val PREF_LOG_RAM_TEMP = "gamebar_log_ram_temp"
const val PREF_LOG_GPU_USAGE = "gamebar_log_gpu_usage"
const val PREF_LOG_GPU_CLOCK = "gamebar_log_gpu_clock"
const val PREF_LOG_GPU_TEMP = "gamebar_log_gpu_temp"
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_gamebar_log, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
try {
initViews(view)
loadSavedLoggingMode() // Load saved mode first
setupRecyclerView()
setupButtonListeners()
loadInstalledApps()
initializeUIState() // Initialize UI based on saved mode
updateButtonStates()
} catch (e: Exception) {
Toast.makeText(requireContext(), "Error initializing log monitor: ${e.message}", Toast.LENGTH_LONG).show()
e.printStackTrace()
}
}
private fun initViews(view: View) {
searchBar = view.findViewById(R.id.search_bar)
startCaptureButton = view.findViewById(R.id.btn_start_capture)
stopCaptureButton = view.findViewById(R.id.btn_stop_capture)
manualLogsButton = view.findViewById(R.id.btn_manual_logs)
logTypeRadioGroup = view.findViewById(R.id.rg_log_type)
globalLoggingRadio = view.findViewById(R.id.rb_global_logging)
perAppLoggingRadio = view.findViewById(R.id.rb_per_app_logging)
logHistoryRecyclerView = view.findViewById(R.id.rv_log_history)
}
private fun setupRecyclerView() {
// Setup global log history adapter
logHistoryAdapter = LogHistoryAdapter(logFiles) { logFile, view ->
showLogFilePopupMenu(logFile, view)
}
// Setup per-app log adapter
perAppLogAdapter = PerAppLogAdapter(
requireContext(),
filteredApps,
onSwitchChanged = { packageName, enabled ->
perAppLogManager.setAppLoggingEnabled(requireContext(), packageName, enabled)
updatePerAppAdapterStates()
},
onViewLogsClicked = { packageName, appName ->
val intent = Intent(requireContext(), PerAppLogViewActivity::class.java).apply {
putExtra(PerAppLogViewActivity.EXTRA_PACKAGE_NAME, packageName)
putExtra(PerAppLogViewActivity.EXTRA_APP_NAME, appName)
}
startActivity(intent)
}
)
logHistoryRecyclerView.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = logHistoryAdapter // Start with global adapter
}
}
private fun setupButtonListeners() {
startCaptureButton.setOnClickListener {
gameDataExport.startCapture()
if (currentLoggingMode == GameDataExport.LoggingMode.PER_APP) {
// Start dedicated PerAppLogService for logging-specific foreground monitoring
requireContext().startService(Intent(requireContext(), PerAppLogService::class.java))
}
val message = if (currentLoggingMode == GameDataExport.LoggingMode.GLOBAL) {
"Started global logging"
} else {
"Per-app logging enabled - logs will start automatically when enabled apps become active"
}
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
updateButtonStates()
}
stopCaptureButton.setOnClickListener {
if (gameDataExport.isCapturing()) {
gameDataExport.stopCapture()
if (currentLoggingMode == GameDataExport.LoggingMode.GLOBAL) {
gameDataExport.exportDataToCsv()
Toast.makeText(requireContext(), "Stopped logging and exported data", Toast.LENGTH_SHORT).show()
loadLogHistory()
} else {
Toast.makeText(requireContext(), "Stopped per-app logging", Toast.LENGTH_SHORT).show()
updatePerAppAdapterStates()
}
}
updateButtonStates()
}
manualLogsButton.setOnClickListener {
showManualLogsDialog()
}
logTypeRadioGroup.setOnCheckedChangeListener { _, checkedId ->
when (checkedId) {
R.id.rb_global_logging -> switchToGlobalMode()
R.id.rb_per_app_logging -> switchToPerAppMode()
}
}
searchBar.addTextChangedListener(object : android.text.TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
when (currentLoggingMode) {
GameDataExport.LoggingMode.GLOBAL -> filterLogs(s.toString().lowercase())
GameDataExport.LoggingMode.PER_APP -> filterApps(s.toString().lowercase())
}
}
override fun afterTextChanged(s: android.text.Editable?) {}
})
}
private fun filterLogs(query: String) {
logFiles.clear()
if (query.isEmpty()) {
logFiles.addAll(allLogFiles)
} else {
allLogFiles.forEach { logFile ->
if (logFile.name.lowercase().contains(query) ||
logFile.lastModified.lowercase().contains(query)) {
logFiles.add(logFile)
}
}
}
logHistoryAdapter.notifyDataSetChanged()
}
private fun updateButtonStates() {
val isCapturing = gameDataExport.isCapturing()
when (currentLoggingMode) {
GameDataExport.LoggingMode.GLOBAL -> {
startCaptureButton.isEnabled = !isCapturing
stopCaptureButton.isEnabled = isCapturing
manualLogsButton.visibility = View.GONE
if (isCapturing) {
startCaptureButton.text = "Capturing..."
} else {
startCaptureButton.text = "Start Capture"
}
}
GameDataExport.LoggingMode.PER_APP -> {
val hasEnabledApps = perAppLogManager.getEnabledApps(requireContext()).isNotEmpty()
val hasManualLogs = getManualLogPackages().isNotEmpty()
startCaptureButton.isEnabled = !isCapturing && hasEnabledApps
stopCaptureButton.isEnabled = isCapturing
manualLogsButton.visibility = if (hasManualLogs) View.VISIBLE else View.GONE
if (isCapturing) {
startCaptureButton.text = "Per-app Logging Active"
} else {
startCaptureButton.text = if (hasEnabledApps) "Enable Per-app Logging" else "No Apps Enabled"
}
}
}
}
private fun getManualLogPackages(): List<String> {
val allLogFiles = perAppLogManager.getAllPerAppLogFiles()
val enabledApps = perAppLogManager.getEnabledApps(requireContext())
// Return packages that have log files but aren't in the enabled apps list
return allLogFiles.keys.filter { packageName ->
!enabledApps.contains(packageName) && packageName != "unknown"
}
}
private fun showManualLogsDialog() {
val manualLogPackages = getManualLogPackages()
if (manualLogPackages.isEmpty()) {
Toast.makeText(requireContext(), "No manual logs found", Toast.LENGTH_SHORT).show()
return
}
val pm = requireContext().packageManager
val items = manualLogPackages.map { packageName ->
try {
val appInfo = pm.getApplicationInfo(packageName, 0)
pm.getApplicationLabel(appInfo).toString()
} catch (e: Exception) {
packageName
}
}.toTypedArray()
AlertDialog.Builder(requireContext())
.setTitle("Manual Logs")
.setItems(items) { _, which ->
val packageName = manualLogPackages[which]
val appName = items[which]
// Open log view for this package
val intent = Intent(requireContext(), PerAppLogViewActivity::class.java).apply {
putExtra(PerAppLogViewActivity.EXTRA_PACKAGE_NAME, packageName)
putExtra(PerAppLogViewActivity.EXTRA_APP_NAME, appName)
}
startActivity(intent)
}
.setNegativeButton("Close", null)
.show()
}
private fun loadLogHistory() {
logFiles.clear()
allLogFiles.clear()
val externalStorageDir = Environment.getExternalStorageDirectory()
val files = externalStorageDir.listFiles { file ->
file.name.startsWith("GameBar_log_") && file.name.endsWith(".csv")
}
files?.let { fileArray ->
fileArray.sortByDescending { it.lastModified() }
for (file in fileArray) {
val logFile = LogFile(
name = file.name,
path = file.absolutePath,
size = formatFileSize(file.length()),
lastModified = formatDate(file.lastModified())
)
allLogFiles.add(logFile)
logFiles.add(logFile)
}
}
logHistoryAdapter.notifyDataSetChanged()
}
private fun showLogFilePopupMenu(logFile: LogFile, anchorView: View) {
val popupMenu = PopupMenu(requireContext(), anchorView)
popupMenu.menuInflater.inflate(R.menu.log_file_popup_menu, popupMenu.menu)
popupMenu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.menu_open -> {
openLogFile(logFile)
true
}
R.id.menu_share -> {
shareLogFile(logFile)
true
}
R.id.menu_export -> {
exportLogFile(logFile)
true
}
R.id.menu_delete -> {
deleteLogFile(logFile)
true
}
else -> false
}
}
popupMenu.show()
}
private fun openLogFile(logFile: LogFile) {
// Show analytics dialog for log file
showLogAnalyticsDialog(logFile)
}
private fun showLogAnalyticsDialog(logFile: LogFile) {
// Show loading message
val loadingDialog = AlertDialog.Builder(requireContext())
.setTitle("Analyzing Log...")
.setMessage("Please wait while we analyze the session data.")
.setCancelable(false)
.create()
loadingDialog.show()
// Analyze log file in background thread
Thread {
val logReader = PerAppLogReader()
val analytics = logReader.analyzeLogFile(logFile.path)
// Update UI on main thread
activity?.runOnUiThread {
loadingDialog.dismiss()
if (analytics != null) {
showAnalyticsInDialog(logFile, analytics)
} else {
Toast.makeText(requireContext(), "Failed to analyze log file", Toast.LENGTH_SHORT).show()
}
}
}.start()
}
private fun showAnalyticsInDialog(logFile: LogFile, analytics: LogAnalytics) {
// Open LogAnalyticsActivity with the file
val intent = Intent(requireContext(), LogAnalyticsActivity::class.java).apply {
putExtra(LogAnalyticsActivity.EXTRA_LOG_FILE_PATH, logFile.path)
putExtra(LogAnalyticsActivity.EXTRA_LOG_FILE_NAME, logFile.name)
}
startActivity(intent)
}
private fun shareLogFile(logFile: LogFile) {
try {
val intent = Intent(Intent.ACTION_SEND)
val file = File(logFile.path)
// Use FileProvider to create content:// URI
val uri = FileProvider.getUriForFile(
requireContext(),
"${requireContext().packageName}.fileprovider",
file
)
intent.type = "text/csv"
intent.putExtra(Intent.EXTRA_STREAM, uri)
intent.putExtra(Intent.EXTRA_SUBJECT, "GameBar Performance Log")
intent.putExtra(Intent.EXTRA_TEXT, "GameBar performance log file: ${logFile.name}")
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val chooser = Intent.createChooser(intent, "Share log file")
startActivity(chooser)
} catch (e: Exception) {
// Fallback with generic type if CSV doesn't work
try {
val intent = Intent(Intent.ACTION_SEND)
val file = File(logFile.path)
val uri = FileProvider.getUriForFile(
requireContext(),
"${requireContext().packageName}.fileprovider",
file
)
intent.type = "text/plain"
intent.putExtra(Intent.EXTRA_STREAM, uri)
intent.putExtra(Intent.EXTRA_SUBJECT, "GameBar Performance Log")
intent.putExtra(Intent.EXTRA_TEXT, "GameBar performance log file: ${logFile.name}")
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val chooser = Intent.createChooser(intent, "Share log file")
startActivity(chooser)
} catch (e2: Exception) {
Toast.makeText(requireContext(), "File location: ${logFile.path}", Toast.LENGTH_LONG).show()
}
}
}
private fun exportLogFile(logFile: LogFile) {
Toast.makeText(requireContext(), "File saved at: ${logFile.path}", Toast.LENGTH_LONG).show()
}
private fun deleteLogFile(logFile: LogFile) {
AlertDialog.Builder(requireContext())
.setTitle("Delete Log File")
.setMessage("Are you sure you want to delete this log file?\n\n${logFile.name}")
.setPositiveButton("Delete") { _, _ ->
try {
val file = File(logFile.path)
if (file.delete()) {
Toast.makeText(requireContext(), "Log file deleted", Toast.LENGTH_SHORT).show()
loadLogHistory() // Refresh the list
} else {
Toast.makeText(requireContext(), "Failed to delete log file", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(requireContext(), "Error deleting file: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton("Cancel", null)
.show()
}
private fun formatFileSize(bytes: Long): String {
val kb = bytes / 1024.0
val mb = kb / 1024.0
return when {
mb >= 1 -> String.format("%.1f MB", mb)
kb >= 1 -> String.format("%.1f KB", kb)
else -> "$bytes bytes"
}
}
private fun formatDate(timestamp: Long): String {
val dateFormat = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault())
return dateFormat.format(Date(timestamp))
}
override fun onResume() {
super.onResume()
gameDataExport.setCaptureStateListener(this)
perAppLogManager.setPerAppStateListener(this)
loadLogHistory()
if (currentLoggingMode == GameDataExport.LoggingMode.PER_APP) {
updatePerAppAdapterStates()
}
// Update button states last to ensure manual logs button visibility is correct
updateButtonStates()
}
override fun onPause() {
super.onPause()
gameDataExport.setCaptureStateListener(null)
perAppLogManager.setPerAppStateListener(null)
}
// CaptureStateListener implementation
override fun onCaptureStarted() {
activity?.runOnUiThread {
updateButtonStates()
}
}
override fun onCaptureStopped() {
activity?.runOnUiThread {
updateButtonStates()
// Small delay to ensure file is written before refreshing
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
if (currentLoggingMode == GameDataExport.LoggingMode.GLOBAL) {
loadLogHistory()
}
}, 500)
}
}
// PerAppStateListener implementation
override fun onAppLoggingStarted(packageName: String) {
activity?.runOnUiThread {
updatePerAppAdapterStates()
}
}
override fun onAppLoggingStopped(packageName: String) {
activity?.runOnUiThread {
updatePerAppAdapterStates()
}
}
private fun loadInstalledApps() {
try {
val pm = requireContext().packageManager
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
installedApps.clear()
installedApps.addAll(apps.filter { app ->
// Filter out system apps and our own app
(app.flags and ApplicationInfo.FLAG_SYSTEM) == 0 &&
app.packageName != requireContext().packageName
}.sortedBy { app ->
app.loadLabel(pm).toString().lowercase()
})
filteredApps.clear()
filteredApps.addAll(installedApps)
} catch (e: Exception) {
Toast.makeText(requireContext(), "Error loading apps: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
private fun loadSavedLoggingMode() {
val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext())
val savedMode = prefs.getString(PREF_LOGGING_MODE, "GLOBAL")
currentLoggingMode = if (savedMode == "PER_APP") {
GameDataExport.LoggingMode.PER_APP
} else {
GameDataExport.LoggingMode.GLOBAL
}
// Set the mode in GameDataExport
gameDataExport.setLoggingMode(currentLoggingMode)
}
private fun saveLoggingMode() {
val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext())
prefs.edit().putString(PREF_LOGGING_MODE, currentLoggingMode.name).apply()
}
private fun initializeUIState() {
// Set radio button state based on loaded mode
when (currentLoggingMode) {
GameDataExport.LoggingMode.GLOBAL -> {
globalLoggingRadio.isChecked = true
logHistoryRecyclerView.adapter = logHistoryAdapter
searchBar.hint = "Search logs..."
loadLogHistory()
}
GameDataExport.LoggingMode.PER_APP -> {
perAppLoggingRadio.isChecked = true
logHistoryRecyclerView.adapter = perAppLogAdapter
searchBar.hint = "Search apps..."
updatePerAppAdapterStates()
}
}
}
private fun switchToGlobalMode() {
if (currentLoggingMode == GameDataExport.LoggingMode.GLOBAL) return
currentLoggingMode = GameDataExport.LoggingMode.GLOBAL
gameDataExport.setLoggingMode(GameDataExport.LoggingMode.GLOBAL)
saveLoggingMode() // Save the mode change
// Switch to global log history adapter
logHistoryRecyclerView.adapter = logHistoryAdapter
searchBar.hint = "Search logs..."
searchBar.setText("") // Clear search when switching modes
loadLogHistory()
updateButtonStates()
}
private fun switchToPerAppMode() {
if (currentLoggingMode == GameDataExport.LoggingMode.PER_APP) return
currentLoggingMode = GameDataExport.LoggingMode.PER_APP
gameDataExport.setLoggingMode(GameDataExport.LoggingMode.PER_APP)
saveLoggingMode() // Save the mode change
// Switch to per-app adapter
logHistoryRecyclerView.adapter = perAppLogAdapter
searchBar.hint = "Search apps..."
searchBar.setText("") // Clear search when switching modes
updatePerAppAdapterStates()
updateButtonStates()
}
private fun filterApps(query: String) {
filteredApps.clear()
if (query.isEmpty()) {
filteredApps.addAll(installedApps)
} else {
installedApps.forEach { app ->
val pm = requireContext().packageManager
val label = app.loadLabel(pm).toString().lowercase()
val pkg = app.packageName.lowercase()
if (label.contains(query) || pkg.contains(query)) {
filteredApps.add(app)
}
}
}
perAppLogAdapter.updateApps(filteredApps)
}
private fun updatePerAppAdapterStates() {
val enabledApps = perAppLogManager.getEnabledApps(requireContext())
val currentlyLoggingApps = perAppLogManager.getCurrentlyLoggingApps()
perAppLogAdapter.updateEnabledApps(enabledApps)
perAppLogAdapter.updateCurrentlyLoggingApps(currentlyLoggingApps)
perAppLogAdapter.refreshLogFileStates() // Refresh to update document icons
updateButtonStates() // Update button states when per-app states change
}
data class LogFile(
val name: String,
val path: String,
val size: String,
val lastModified: String
)
}

View File

@@ -0,0 +1,102 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import java.io.BufferedReader
import java.io.FileReader
import java.io.IOException
object GameBarMemInfo {
fun getRamUsage(): String {
var memTotal = 0L
var memAvailable = 0L
try {
BufferedReader(FileReader("/proc/meminfo")).use { br ->
var line: String?
while (br.readLine().also { line = it } != null) {
line?.let {
when {
it.startsWith("MemTotal:") -> memTotal = parseMemValue(it)
it.startsWith("MemAvailable:") -> memAvailable = parseMemValue(it)
}
if (memTotal > 0 && memAvailable > 0) {
return@use
}
}
}
}
} catch (e: IOException) {
return "N/A"
}
if (memTotal == 0L) {
return "N/A"
}
val usedKb = memTotal - memAvailable
val usedMb = usedKb / 1024
return usedMb.toString()
}
private fun parseMemValue(line: String): Long {
val parts = line.split("\\s+".toRegex())
if (parts.size < 3) {
return 0L
}
return try {
parts[1].toLong()
} catch (e: NumberFormatException) {
0L
}
}
fun getRamSpeed(): String {
val path = "/sys/devices/system/cpu/bus_dcvs/DDR/cur_freq"
try {
BufferedReader(FileReader(path)).use { br ->
val line = br.readLine()
if (!line.isNullOrEmpty()) {
try {
val khz = line.trim().toInt()
val mhz = khz / 1000f
return if (mhz >= 1000) {
String.format("%.3f GHz", mhz / 1000f)
} else {
String.format("%.0f MHz", mhz)
}
} catch (ignored: NumberFormatException) {
}
}
}
} catch (e: IOException) {
// ignore
}
return "N/A"
}
fun getRamTemp(): String {
val path = "/sys/class/thermal/thermal_zone27/temp"
try {
BufferedReader(FileReader(path)).use { br ->
val line = br.readLine()
if (!line.isNullOrEmpty()) {
try {
val raw = line.trim().toInt()
val celsius = raw / 1000f
return String.format("%.1f°C", celsius)
} catch (ignored: NumberFormatException) {
}
}
}
} catch (e: IOException) {
// ignore
}
return "N/A"
}
}

View File

@@ -0,0 +1,150 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.app.Service
import android.content.Intent
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import androidx.preference.PreferenceManager
class GameBarMonitorService : Service() {
private var handler: Handler? = null
private var monitorRunnable: Runnable? = null
@Volatile
private var isRunning = false
private var lastForegroundApp = ""
private var lastGameBarState = false
private val perAppLogManager = PerAppLogManager.getInstance()
companion object {
private const val MONITOR_INTERVAL = 2000L // 2 seconds
}
override fun onCreate() {
super.onCreate()
handler = Handler(Looper.getMainLooper())
monitorRunnable = object : Runnable {
override fun run() {
if (isRunning) {
monitorForegroundApp()
if (isRunning && handler != null) {
handler!!.postDelayed(this, MONITOR_INTERVAL)
}
}
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!isRunning) {
isRunning = true
handler?.let { h ->
monitorRunnable?.let { r ->
h.post(r)
}
}
}
return START_STICKY
}
private fun monitorForegroundApp() {
try {
if (!isRunning) return
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
val masterEnabled = prefs.getBoolean("game_bar_enable", false)
if (masterEnabled) {
if (!lastGameBarState) {
val gameBar = GameBar.getInstance(this)
gameBar.applyPreferences()
gameBar.show()
lastGameBarState = true
}
return
}
val autoEnabled = prefs.getBoolean("game_bar_auto_enable", false)
if (!autoEnabled) {
if (lastGameBarState) {
GameBar.getInstance(this).hide()
lastGameBarState = false
}
return
}
val foreground = ForegroundAppDetector.getForegroundPackageName(this)
// Only update if foreground app changed
if (foreground != lastForegroundApp) {
val autoApps = prefs.getStringSet(
GameBarPerAppConfigFragment.PREF_AUTO_APPS,
emptySet()
) ?: emptySet()
val shouldShow = autoApps.contains(foreground)
if (shouldShow && !lastGameBarState) {
val gameBar = GameBar.getInstance(this)
gameBar.applyPreferences()
gameBar.show()
lastGameBarState = true
} else if (!shouldShow && lastGameBarState) {
GameBar.getInstance(this).hide()
lastGameBarState = false
}
// Handle per-app logging state changes
if (lastForegroundApp.isNotEmpty() && lastForegroundApp != "Unknown") {
perAppLogManager.onAppWentToBackground(this, lastForegroundApp)
}
if (foreground.isNotEmpty() && foreground != "Unknown") {
perAppLogManager.onAppBecameForeground(this, foreground)
}
lastForegroundApp = foreground
}
} catch (e: Exception) {
// Prevent crashes from propagating
android.util.Log.e("GameBarMonitorService", "Error in monitorForegroundApp", e)
}
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onDestroy() {
super.onDestroy()
isRunning = false
handler?.let {
monitorRunnable?.let { runnable ->
it.removeCallbacks(runnable)
}
it.removeCallbacksAndMessages(null)
}
// Clean up GameBar instance
try {
GameBar.destroyInstance()
} catch (e: Exception) {
android.util.Log.e("GameBarMonitorService", "Error destroying GameBar instance", e)
}
// Clear state variables to prevent lingering references
lastForegroundApp = ""
lastGameBarState = false
handler = null
monitorRunnable = null
}
}

View File

@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.os.Bundle
import com.android.settingslib.collapsingtoolbar.CollapsingToolbarBaseActivity
import com.android.gamebar.R
class GameBarPerAppConfigActivity : CollapsingToolbarBaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_game_bar_app_selector)
title = "Configure Per-App GameBar"
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(R.id.content_frame, GameBarPerAppConfigFragment())
.commit()
}
}
}

View File

@@ -0,0 +1,117 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.LinearLayout
import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
import androidx.preference.SwitchPreferenceCompat
import com.android.gamebar.R
class GameBarPerAppConfigFragment : PreferenceFragmentCompat() {
companion object {
const val PREF_AUTO_APPS = "game_bar_auto_apps"
}
private var searchBar: EditText? = null
private var category: PreferenceCategory? = null
private var allApps: List<ApplicationInfo>? = null
private var pm: PackageManager? = null
private var autoApps: Set<String>? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val root = super.onCreateView(inflater, container, savedInstanceState)
val context = requireContext()
val layout = LinearLayout(context)
layout.orientation = LinearLayout.VERTICAL
searchBar = EditText(context).apply {
id = View.generateViewId()
hint = "Search apps..."
inputType = android.text.InputType.TYPE_CLASS_TEXT
setBackgroundResource(R.drawable.bg_search_rounded)
setPadding(24, 24, 24, 24)
setTextColor(context.getColor(R.color.app_name_text_selector))
setHintTextColor(context.getColor(R.color.app_package_text_selector))
}
val params = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
val margin = (context.resources.displayMetrics.density * 16).toInt() // 16dp
params.setMargins(margin, 0, margin, 0)
layout.addView(searchBar, params)
root?.let { layout.addView(it) }
searchBar?.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
populateAppList(s.toString().lowercase())
}
override fun afterTextChanged(s: Editable?) {}
})
return layout
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
preferenceScreen = preferenceManager.createPreferenceScreen(requireContext())
category = PreferenceCategory(requireContext()).apply {
title = "Configure Per-App GameBar"
}
preferenceScreen.addPreference(category!!)
pm = requireContext().packageManager
allApps = pm!!.getInstalledApplications(PackageManager.GET_META_DATA)
autoApps = PreferenceManager.getDefaultSharedPreferences(requireContext())
.getStringSet(PREF_AUTO_APPS, emptySet())
populateAppList("")
}
private fun populateAppList(filter: String) {
category?.removeAll()
allApps?.forEach { app ->
if ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) return@forEach
if (app.packageName == requireContext().packageName) return@forEach
val label = app.loadLabel(pm!!).toString().lowercase()
val pkg = app.packageName.lowercase()
if (filter.isNotEmpty() && !(label.contains(filter) || pkg.contains(filter))) return@forEach
val pref = SwitchPreferenceCompat(requireContext()).apply {
title = app.loadLabel(pm!!)
summary = app.packageName
key = "gamebar_${app.packageName}"
isChecked = autoApps?.contains(app.packageName) == true
icon = app.loadIcon(pm!!)
onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val updated = PreferenceManager.getDefaultSharedPreferences(requireContext())
.getStringSet(PREF_AUTO_APPS, emptySet())?.toMutableSet() ?: mutableSetOf()
if (newValue as Boolean) {
updated.add(app.packageName)
} else {
updated.remove(app.packageName)
}
PreferenceManager.getDefaultSharedPreferences(requireContext())
.edit().putStringSet(PREF_AUTO_APPS, updated).apply()
true
}
}
category?.addPreference(pref)
}
}
}

View File

@@ -0,0 +1,133 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import com.android.settingslib.collapsingtoolbar.CollapsingToolbarBaseActivity
import com.android.gamebar.R
class GameBarSettingsActivity : CollapsingToolbarBaseActivity() {
companion object {
private const val OVERLAY_PERMISSION_REQUEST_CODE = 1234
private const val REQUEST_CODE_OPEN_CSV = 1001
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_game_bar)
title = getString(R.string.game_bar_title)
if (!Settings.canDrawOverlays(this)) {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:$packageName")
)
startActivityForResult(intent, OVERLAY_PERMISSION_REQUEST_CODE)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
OVERLAY_PERMISSION_REQUEST_CODE -> {
if (Settings.canDrawOverlays(this)) {
Toast.makeText(this, R.string.overlay_permission_granted, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, R.string.overlay_permission_denied, Toast.LENGTH_SHORT).show()
}
}
REQUEST_CODE_OPEN_CSV -> {
if (resultCode == RESULT_OK) {
data?.data?.also { uri ->
handleExternalLogFile(uri)
}
}
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.gamebar_settings_menu, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_log_monitor -> {
try {
startActivity(Intent(this, GameBarLogActivity::class.java))
} catch (e: Exception) {
Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_LONG).show()
e.printStackTrace()
}
true
}
R.id.menu_open_external_log -> {
openExternalLogFile()
true
}
R.id.menu_user_guide -> {
try {
val url = getString(R.string.game_bar_user_guide_url)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(intent)
} catch (e: Exception) {
Toast.makeText(this, "Unable to open user guide: ${e.message}", Toast.LENGTH_LONG).show()
e.printStackTrace()
}
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun openExternalLogFile() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "text/*"
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("text/csv", "text/comma-separated-values", "text/plain"))
}
try {
startActivityForResult(intent, REQUEST_CODE_OPEN_CSV)
} catch (e: Exception) {
Toast.makeText(this, "File picker not available: ${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun handleExternalLogFile(uri: Uri) {
try {
// Copy file content to temporary location
val inputStream = contentResolver.openInputStream(uri)
val tempFile = java.io.File(cacheDir, "temp_external_log.csv")
inputStream?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
// Open analytics activity with this file
val intent = Intent(this, LogAnalyticsActivity::class.java).apply {
putExtra(LogAnalyticsActivity.EXTRA_LOG_FILE_PATH, tempFile.absolutePath)
putExtra(LogAnalyticsActivity.EXTRA_LOG_FILE_NAME, uri.lastPathSegment ?: "External Log")
}
startActivity(intent)
} catch (e: Exception) {
Toast.makeText(this, "Error opening file: ${e.message}", Toast.LENGTH_LONG).show()
e.printStackTrace()
}
}
}

View File

@@ -0,0 +1,107 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.preference.PreferenceManager
import com.android.gamebar.R
class GameBarTileService : TileService() {
private lateinit var gameBar: GameBar
override fun onCreate() {
super.onCreate()
gameBar = GameBar.getInstance(this)
}
private fun ensureGameBarInstance() {
if (!GameBar.isInstanceCreated()) {
gameBar = GameBar.getInstance(this)
}
}
override fun onStartListening() {
val enabled = PreferenceManager.getDefaultSharedPreferences(this)
.getBoolean("game_bar_enable", false)
updateTileState(enabled)
}
override fun onClick() {
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
val currentlyEnabled = prefs.getBoolean("game_bar_enable", false)
val newState = !currentlyEnabled
// Update preference with apply() instead of commit() for better performance
prefs.edit()
.putBoolean("game_bar_enable", newState)
.apply()
updateTileState(newState)
if (newState) {
// Ensure we have overlay permission before showing
if (android.provider.Settings.canDrawOverlays(this)) {
android.util.Log.d("GameBarTileService", "Enabling GameBar from tile")
// Ensure we have a GameBar instance
ensureGameBarInstance()
// Force cleanup any existing overlay first
gameBar.hide()
gameBar.applyPreferences()
gameBar.show()
// Start monitor service if needed
val autoEnabled = prefs.getBoolean("game_bar_auto_enable", false)
if (autoEnabled) {
startService(android.content.Intent(this, GameBarMonitorService::class.java))
}
} else {
// Revert the preference if we don't have permission
prefs.edit()
.putBoolean("game_bar_enable", false)
.apply()
updateTileState(false)
// Show permission request intent
val intent = android.content.Intent(android.provider.Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
android.net.Uri.parse("package:$packageName"))
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
} else {
android.util.Log.d("GameBarTileService", "Disabling GameBar from tile")
// Ensure we have a GameBar instance to hide
ensureGameBarInstance()
// Force hide the overlay
gameBar.hide()
// Also destroy the singleton to ensure clean state
GameBar.destroyInstance()
// Stop monitor service if auto-enable is also disabled
val autoEnabled = prefs.getBoolean("game_bar_auto_enable", false)
if (!autoEnabled) {
stopService(android.content.Intent(this, GameBarMonitorService::class.java))
}
}
}
private fun updateTileState(enabled: Boolean) {
val tile = qsTile ?: return
tile.state = if (enabled) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
tile.label = getString(R.string.game_bar_tile_label)
tile.contentDescription = getString(R.string.game_bar_tile_description)
tile.updateTile()
}
}

View File

@@ -0,0 +1,216 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.os.Environment
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.*
class GameDataExport private constructor() {
interface CaptureStateListener {
fun onCaptureStarted()
fun onCaptureStopped()
}
enum class LoggingMode {
GLOBAL, PER_APP
}
companion object {
@Volatile
private var INSTANCE: GameDataExport? = null
@JvmStatic
@Synchronized
fun getInstance(): GameDataExport {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: GameDataExport().also { INSTANCE = it }
}
}
private const val MAX_ROWS = 10000 // Prevent unlimited memory growth
private val CSV_HEADER = arrayOf(
"DateTime",
"PackageName",
"FPS",
"Frame_Time",
"Battery_Temp",
"CPU_Usage",
"CPU_Clock",
"CPU_Temp",
"RAM_Usage",
"RAM_Speed",
"RAM_Temp",
"GPU_Usage",
"GPU_Clock",
"GPU_Temp"
)
}
@Volatile
private var capturing = false
private var currentLoggingMode = LoggingMode.PER_APP // Default to per-app logging
private val statsRows = mutableListOf<Array<String>>()
private var listener: CaptureStateListener? = null
private val perAppLogManager = PerAppLogManager.getInstance()
fun setCaptureStateListener(listener: CaptureStateListener?) {
this.listener = listener
}
fun startCapture() {
capturing = true
if (currentLoggingMode == LoggingMode.GLOBAL) {
statsRows.clear()
statsRows.add(CSV_HEADER)
}
listener?.onCaptureStarted()
}
fun stopCapture() {
capturing = false
if (currentLoggingMode == LoggingMode.PER_APP) {
perAppLogManager.stopAllPerAppLogging()
}
listener?.onCaptureStopped()
}
fun clearData() {
statsRows.clear()
statsRows.add(CSV_HEADER)
}
fun getDataSize(): Int {
return statsRows.size
}
fun isCapturing(): Boolean {
return capturing
}
fun addOverlayData(
dateTime: String,
packageName: String,
fps: String,
frameTime: String,
batteryTemp: String,
cpuUsage: String,
cpuClock: String,
cpuTemp: String,
ramUsage: String,
ramSpeed: String,
ramTemp: String,
gpuUsage: String,
gpuClock: String,
gpuTemp: String
) {
if (!capturing) return
when (currentLoggingMode) {
LoggingMode.GLOBAL -> {
// Prevent unlimited memory growth
if (statsRows.size >= MAX_ROWS) {
// Remove oldest entries but keep header
if (statsRows.size > 1) {
val toRemove = statsRows.size / 2
repeat(toRemove) {
if (statsRows.size > 1) {
statsRows.removeAt(1)
}
}
}
}
val row = arrayOf(
dateTime,
packageName,
fps,
frameTime,
batteryTemp,
cpuUsage,
cpuClock,
cpuTemp,
ramUsage,
ramSpeed,
ramTemp,
gpuUsage,
gpuClock,
gpuTemp
)
statsRows.add(row)
}
LoggingMode.PER_APP -> {
// Add data to per-app logging system
perAppLogManager.addPerAppData(
packageName,
dateTime,
fps,
frameTime,
batteryTemp,
cpuUsage,
cpuClock,
cpuTemp,
ramUsage,
ramSpeed,
ramTemp,
gpuUsage,
gpuClock,
gpuTemp
)
}
}
}
fun exportDataToCsv() {
if (currentLoggingMode == LoggingMode.GLOBAL) {
if (statsRows.size <= 1) {
return
}
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val outFile = File(Environment.getExternalStorageDirectory(), "GameBar_log_$timeStamp.csv")
var bw: BufferedWriter? = null
try {
bw = BufferedWriter(FileWriter(outFile, true))
for (row in statsRows) {
bw.write(toCsvLine(row))
bw.newLine()
}
bw.flush()
} catch (ignored: IOException) {
} finally {
bw?.let {
try { it.close() } catch (ignored: IOException) {}
}
}
}
// Per-app logs are exported automatically when sessions end
}
private fun toCsvLine(columns: Array<String>): String {
return columns.joinToString(",")
}
fun setLoggingMode(mode: LoggingMode) {
currentLoggingMode = mode
}
fun getLoggingMode(): LoggingMode {
return currentLoggingMode
}
fun getPerAppLogManager(): PerAppLogManager {
return perAppLogManager
}
}

View File

@@ -0,0 +1,212 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View
import kotlin.math.max
class GpuClockGraphView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#00BCD4") // Cyan for GPU clock
strokeWidth = 3f
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(
0f, 0f, 0f, 600f,
Color.parseColor("#8000BCD4"),
Color.parseColor("#00000000"),
Shader.TileMode.CLAMP
)
style = Paint.Style.FILL
}
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#30FFFFFF")
strokeWidth = 1f
style = Paint.Style.STROKE
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FFFFFF")
textSize = 28f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
}
private val avgLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FF9800")
strokeWidth = 2f
style = Paint.Style.STROKE
pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
}
private var clockData: List<Pair<Long, Double>> = emptyList()
private var avgClock: Double = 0.0
private var maxClock = 1000.0 // Dynamic max based on data
private var minClock = 0.0
private val padding = 80f
private val topPadding = 40f
private val bottomPadding = 80f
fun setData(data: List<Pair<Long, Double>>, avg: Double) {
this.clockData = data
this.avgClock = avg
// Calculate dynamic max (round up to nearest 100)
if (data.isNotEmpty()) {
val dataMax = data.maxOf { it.second }
maxClock = ((dataMax / 100).toInt() + 1) * 100.0
}
post {
fillPaint.shader = LinearGradient(
0f, topPadding, 0f, height - bottomPadding,
Color.parseColor("#8000BCD4"),
Color.parseColor("#1000BCD4"),
Shader.TileMode.CLAMP
)
invalidate()
}
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (clockData.isEmpty()) {
drawEmptyState(canvas)
return
}
val graphWidth = width - 2 * padding
val graphHeight = height - topPadding - bottomPadding
drawGrid(canvas, graphWidth, graphHeight)
drawAverageLine(canvas, graphWidth, graphHeight)
drawGraph(canvas, graphWidth, graphHeight)
drawTitle(canvas)
}
private fun drawEmptyState(canvas: Canvas) {
val message = "No GPU clock data available"
val textWidth = textPaint.measureText(message)
canvas.drawText(
message,
(width - textWidth) / 2,
height / 2f,
textPaint
)
}
private fun drawGrid(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
// Calculate clock steps based on maxClock
val step = (maxClock / 4).toInt()
val clockSteps = (0..4).map { it * step }
for (clock in clockSteps) {
val y = (topPadding + graphHeight * (1 - clock / maxClock)).toFloat()
canvas.drawLine(padding, y, padding + graphWidth, y, gridPaint)
val label = "${clock}MHz"
canvas.drawText(label, padding - 75f, y + 10f, textPaint)
}
// Y-axis label
canvas.save()
canvas.rotate(-90f, 15f, height / 2f)
val yLabel = "GPU Clock (MHz)"
val yLabelWidth = textPaint.measureText(yLabel)
canvas.drawText(yLabel, (width - yLabelWidth) / 2, 30f, textPaint)
canvas.restore()
// Time labels
if (clockData.isNotEmpty()) {
val startTime = clockData.first().first
val endTime = clockData.last().first
val duration = endTime - startTime
canvas.drawText("0s", padding, height - bottomPadding + 25f, textPaint)
val middleTime = formatDuration(duration / 2)
canvas.drawText(middleTime, padding + graphWidth / 2 - 30f, height - bottomPadding + 25f, textPaint)
val endTimeStr = formatDuration(duration)
val endX = padding + graphWidth - textPaint.measureText(endTimeStr)
canvas.drawText(endTimeStr, endX, height - bottomPadding + 25f, textPaint)
}
val xLabel = "Time"
val xLabelWidth = textPaint.measureText(xLabel)
canvas.drawText(xLabel, (width - xLabelWidth) / 2, height - bottomPadding + 55f, textPaint)
}
private fun drawAverageLine(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
val y = (topPadding + graphHeight * (1 - avgClock / maxClock)).toFloat()
canvas.drawLine(padding, y, padding + graphWidth, y, avgLinePaint)
}
private fun drawGraph(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
if (clockData.size < 2) return
val startTime = clockData.first().first
val endTime = clockData.last().first
val timeDuration = max(endTime - startTime, 1L)
val path = Path()
val fillPath = Path()
clockData.forEachIndexed { index, (timestamp, clock) ->
val x = padding + ((timestamp - startTime).toFloat() / timeDuration) * graphWidth
val y = (topPadding + graphHeight * (1 - clock / maxClock)).toFloat()
if (index == 0) {
path.moveTo(x, y)
fillPath.moveTo(x, height - bottomPadding)
fillPath.lineTo(x, y)
} else {
path.lineTo(x, y)
fillPath.lineTo(x, y)
}
}
fillPath.lineTo(padding + graphWidth, height - bottomPadding)
fillPath.close()
canvas.drawPath(fillPath, fillPaint)
canvas.drawPath(path, linePaint)
}
private fun drawTitle(canvas: Canvas) {
val title = "GPU Clock Speed vs Time"
val titleWidth = textPaint.measureText(title)
canvas.drawText(title, (width - titleWidth) / 2, topPadding - 10f, textPaint)
}
private fun formatDuration(durationMs: Long): String {
val seconds = durationMs / 1000
return if (seconds < 60) {
"${seconds}s"
} else {
val minutes = seconds / 60
val remainingSeconds = seconds % 60
"${minutes}m ${remainingSeconds}s"
}
}
}

View File

@@ -0,0 +1,212 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View
import kotlin.math.max
class GpuTempGraphView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#E91E63") // Pink for GPU temperature
strokeWidth = 3f
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(
0f, 0f, 0f, 600f,
Color.parseColor("#80E91E63"),
Color.parseColor("#00000000"),
Shader.TileMode.CLAMP
)
style = Paint.Style.FILL
}
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#30FFFFFF")
strokeWidth = 1f
style = Paint.Style.STROKE
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FFFFFF")
textSize = 28f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
}
private val avgLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FF9800")
strokeWidth = 2f
style = Paint.Style.STROKE
pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
}
private var tempData: List<Pair<Long, Double>> = emptyList()
private var avgTemp: Double = 0.0
private var maxTemp = 100.0 // Dynamic max based on data
private var minTemp = 0.0
private val padding = 80f
private val topPadding = 40f
private val bottomPadding = 80f
fun setData(data: List<Pair<Long, Double>>, avg: Double) {
this.tempData = data
this.avgTemp = avg
// Calculate dynamic max (round up to nearest 10)
if (data.isNotEmpty()) {
val dataMax = data.maxOf { it.second }
maxTemp = ((dataMax / 10).toInt() + 1) * 10.0
}
post {
fillPaint.shader = LinearGradient(
0f, topPadding, 0f, height - bottomPadding,
Color.parseColor("#80E91E63"),
Color.parseColor("#10E91E63"),
Shader.TileMode.CLAMP
)
invalidate()
}
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (tempData.isEmpty()) {
drawEmptyState(canvas)
return
}
val graphWidth = width - 2 * padding
val graphHeight = height - topPadding - bottomPadding
drawGrid(canvas, graphWidth, graphHeight)
drawAverageLine(canvas, graphWidth, graphHeight)
drawGraph(canvas, graphWidth, graphHeight)
drawTitle(canvas)
}
private fun drawEmptyState(canvas: Canvas) {
val message = "No GPU temperature data available"
val textWidth = textPaint.measureText(message)
canvas.drawText(
message,
(width - textWidth) / 2,
height / 2f,
textPaint
)
}
private fun drawGrid(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
// Calculate temp steps based on maxTemp
val step = (maxTemp / 4).toInt()
val tempSteps = (0..4).map { it * step }
for (temp in tempSteps) {
val y = (topPadding + graphHeight * (1 - temp / maxTemp)).toFloat()
canvas.drawLine(padding, y, padding + graphWidth, y, gridPaint)
val label = "${temp}°C"
canvas.drawText(label, padding - 70f, y + 10f, textPaint)
}
// Y-axis label
canvas.save()
canvas.rotate(-90f, 15f, height / 2f)
val yLabel = "GPU Temperature (°C)"
val yLabelWidth = textPaint.measureText(yLabel)
canvas.drawText(yLabel, (width - yLabelWidth) / 2, 30f, textPaint)
canvas.restore()
// Time labels
if (tempData.isNotEmpty()) {
val startTime = tempData.first().first
val endTime = tempData.last().first
val duration = endTime - startTime
canvas.drawText("0s", padding, height - bottomPadding + 25f, textPaint)
val middleTime = formatDuration(duration / 2)
canvas.drawText(middleTime, padding + graphWidth / 2 - 30f, height - bottomPadding + 25f, textPaint)
val endTimeStr = formatDuration(duration)
val endX = padding + graphWidth - textPaint.measureText(endTimeStr)
canvas.drawText(endTimeStr, endX, height - bottomPadding + 25f, textPaint)
}
val xLabel = "Time"
val xLabelWidth = textPaint.measureText(xLabel)
canvas.drawText(xLabel, (width - xLabelWidth) / 2, height - bottomPadding + 55f, textPaint)
}
private fun drawAverageLine(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
val y = (topPadding + graphHeight * (1 - avgTemp / maxTemp)).toFloat()
canvas.drawLine(padding, y, padding + graphWidth, y, avgLinePaint)
}
private fun drawGraph(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
if (tempData.size < 2) return
val startTime = tempData.first().first
val endTime = tempData.last().first
val timeDuration = max(endTime - startTime, 1L)
val path = Path()
val fillPath = Path()
tempData.forEachIndexed { index, (timestamp, temp) ->
val x = padding + ((timestamp - startTime).toFloat() / timeDuration) * graphWidth
val y = (topPadding + graphHeight * (1 - temp / maxTemp)).toFloat()
if (index == 0) {
path.moveTo(x, y)
fillPath.moveTo(x, height - bottomPadding)
fillPath.lineTo(x, y)
} else {
path.lineTo(x, y)
fillPath.lineTo(x, y)
}
}
fillPath.lineTo(padding + graphWidth, height - bottomPadding)
fillPath.close()
canvas.drawPath(fillPath, fillPaint)
canvas.drawPath(path, linePaint)
}
private fun drawTitle(canvas: Canvas) {
val title = "GPU Temperature vs Time"
val titleWidth = textPaint.measureText(title)
canvas.drawText(title, (width - titleWidth) / 2, topPadding - 10f, textPaint)
}
private fun formatDuration(durationMs: Long): String {
val seconds = durationMs / 1000
return if (seconds < 60) {
"${seconds}s"
} else {
val minutes = seconds / 60
val remainingSeconds = seconds % 60
"${minutes}m ${remainingSeconds}s"
}
}
}

View File

@@ -0,0 +1,204 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View
import kotlin.math.max
class GpuUsageGraphView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#9C27B0") // Purple for GPU
strokeWidth = 3f
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(
0f, 0f, 0f, 600f,
Color.parseColor("#809C27B0"),
Color.parseColor("#00000000"),
Shader.TileMode.CLAMP
)
style = Paint.Style.FILL
}
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#30FFFFFF")
strokeWidth = 1f
style = Paint.Style.STROKE
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FFFFFF")
textSize = 28f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
}
private val avgLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FF9800")
strokeWidth = 2f
style = Paint.Style.STROKE
pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
}
private var gpuData: List<Pair<Long, Double>> = emptyList()
private var avgGpu: Double = 0.0
private val maxGpu = 100.0
private val minGpu = 0.0
private val padding = 80f
private val topPadding = 40f
private val bottomPadding = 80f
fun setData(data: List<Pair<Long, Double>>, avg: Double) {
this.gpuData = data
this.avgGpu = avg
post {
fillPaint.shader = LinearGradient(
0f, topPadding, 0f, height - bottomPadding,
Color.parseColor("#809C27B0"),
Color.parseColor("#109C27B0"),
Shader.TileMode.CLAMP
)
invalidate()
}
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (gpuData.isEmpty()) {
drawEmptyState(canvas)
return
}
val graphWidth = width - 2 * padding
val graphHeight = height - topPadding - bottomPadding
drawGrid(canvas, graphWidth, graphHeight)
drawAverageLine(canvas, graphWidth, graphHeight)
drawGraph(canvas, graphWidth, graphHeight)
drawTitle(canvas)
}
private fun drawEmptyState(canvas: Canvas) {
val message = "No GPU usage data available"
val textWidth = textPaint.measureText(message)
canvas.drawText(
message,
(width - textWidth) / 2,
height / 2f,
textPaint
)
}
private fun drawGrid(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
val gpuSteps = listOf(0, 25, 50, 75, 100)
for (gpu in gpuSteps) {
val y = (topPadding + graphHeight * (1 - gpu / maxGpu.toFloat())).toFloat()
canvas.drawLine(padding, y, padding + graphWidth, y, gridPaint)
val label = "$gpu%"
canvas.drawText(label, padding - 70f, y + 10f, textPaint)
}
// Y-axis label
canvas.save()
canvas.rotate(-90f, 15f, height / 2f)
val yLabel = "GPU Usage (%)"
val yLabelWidth = textPaint.measureText(yLabel)
canvas.drawText(yLabel, (width - yLabelWidth) / 2, 30f, textPaint)
canvas.restore()
// Time labels
if (gpuData.isNotEmpty()) {
val startTime = gpuData.first().first
val endTime = gpuData.last().first
val duration = endTime - startTime
canvas.drawText("0s", padding, height - bottomPadding + 25f, textPaint)
val middleTime = formatDuration(duration / 2)
canvas.drawText(middleTime, padding + graphWidth / 2 - 30f, height - bottomPadding + 25f, textPaint)
val endTimeStr = formatDuration(duration)
val endX = padding + graphWidth - textPaint.measureText(endTimeStr)
canvas.drawText(endTimeStr, endX, height - bottomPadding + 25f, textPaint)
}
val xLabel = "Time"
val xLabelWidth = textPaint.measureText(xLabel)
canvas.drawText(xLabel, (width - xLabelWidth) / 2, height - bottomPadding + 55f, textPaint)
}
private fun drawAverageLine(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
val y = (topPadding + graphHeight * (1 - avgGpu / maxGpu)).toFloat()
canvas.drawLine(padding, y, padding + graphWidth, y, avgLinePaint)
}
private fun drawGraph(canvas: Canvas, graphWidth: Float, graphHeight: Float) {
if (gpuData.size < 2) return
val startTime = gpuData.first().first
val endTime = gpuData.last().first
val timeDuration = max(endTime - startTime, 1L)
val path = Path()
val fillPath = Path()
gpuData.forEachIndexed { index, (timestamp, gpu) ->
val x = padding + ((timestamp - startTime).toFloat() / timeDuration) * graphWidth
val y = (topPadding + graphHeight * (1 - gpu / maxGpu)).toFloat()
if (index == 0) {
path.moveTo(x, y)
fillPath.moveTo(x, height - bottomPadding)
fillPath.lineTo(x, y)
} else {
path.lineTo(x, y)
fillPath.lineTo(x, y)
}
}
fillPath.lineTo(padding + graphWidth, height - bottomPadding)
fillPath.close()
canvas.drawPath(fillPath, fillPaint)
canvas.drawPath(path, linePaint)
}
private fun drawTitle(canvas: Canvas) {
val title = "GPU Usage vs Time"
val titleWidth = textPaint.measureText(title)
canvas.drawText(title, (width - titleWidth) / 2, topPadding - 10f, textPaint)
}
private fun formatDuration(durationMs: Long): String {
val seconds = durationMs / 1000
return if (seconds < 60) {
"${seconds}s"
} else {
val minutes = seconds / 60
val remainingSeconds = seconds % 60
"${minutes}m ${remainingSeconds}s"
}
}
}

View File

@@ -0,0 +1,73 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.app.AlertDialog
import android.os.Bundle
import android.widget.Toast
import com.android.settingslib.collapsingtoolbar.CollapsingToolbarBaseActivity
import com.android.gamebar.R
class LogAnalyticsActivity : CollapsingToolbarBaseActivity() {
companion object {
const val EXTRA_LOG_FILE_PATH = "log_file_path"
const val EXTRA_LOG_FILE_NAME = "log_file_name"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_gamebar_log)
val logFilePath = intent.getStringExtra(EXTRA_LOG_FILE_PATH) ?: ""
val logFileName = intent.getStringExtra(EXTRA_LOG_FILE_NAME) ?: "Log Analytics"
title = logFileName
if (savedInstanceState == null && logFilePath.isNotEmpty()) {
showAnalytics(logFilePath, logFileName)
}
}
private fun showAnalytics(filePath: String, fileName: String) {
// Show loading message
val loadingDialog = AlertDialog.Builder(this)
.setTitle("Analyzing Log...")
.setMessage("Please wait while we analyze the session data.")
.setCancelable(false)
.create()
loadingDialog.show()
// Analyze log file in background thread
Thread {
val logReader = PerAppLogReader()
val analytics = logReader.analyzeLogFile(filePath)
// Update UI on main thread
runOnUiThread {
loadingDialog.dismiss()
if (analytics != null) {
// Create and show fragment with analytics
val fragment = LogAnalyticsFragment().apply {
arguments = Bundle().apply {
putString("LOG_FILE_PATH", filePath)
putString("LOG_FILE_NAME", fileName)
putSerializable("ANALYTICS_DATA", analytics)
}
}
supportFragmentManager.beginTransaction()
.replace(R.id.content_frame, fragment)
.commit()
} else {
Toast.makeText(this, "Failed to analyze log file", Toast.LENGTH_SHORT).show()
finish()
}
}
}.start()
}
}

View File

@@ -0,0 +1,148 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.Fragment
import com.android.gamebar.R
class LogAnalyticsFragment : Fragment() {
private var analytics: LogAnalytics? = null
private var logFileName: String = "Log"
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val logFilePath = arguments?.getString("LOG_FILE_PATH") ?: ""
logFileName = arguments?.getString("LOG_FILE_NAME") ?: "Log"
analytics = arguments?.getSerializable("ANALYTICS_DATA") as? LogAnalytics
if (analytics == null) {
Toast.makeText(requireContext(), "Failed to load analytics data", Toast.LENGTH_SHORT).show()
return inflater.inflate(R.layout.dialog_log_analytics, container, false)
}
return inflater.inflate(R.layout.dialog_log_analytics, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val analyticsData = analytics ?: return
// Get all views from the layout
val sessionInfoText = view.findViewById<TextView>(R.id.tv_session_info)
val fpsGraphView = view.findViewById<FpsGraphView>(R.id.fps_graph_view)
val frameTimeGraphView = view.findViewById<FrameTimeGraphView>(R.id.frame_time_graph_view)
val maxFpsText = view.findViewById<TextView>(R.id.tv_max_fps)
val minFpsText = view.findViewById<TextView>(R.id.tv_min_fps)
val avgFpsText = view.findViewById<TextView>(R.id.tv_avg_fps)
val varianceText = view.findViewById<TextView>(R.id.tv_variance)
val stdDevText = view.findViewById<TextView>(R.id.tv_std_dev)
val smoothnessText = view.findViewById<TextView>(R.id.tv_smoothness)
val fps1PercentText = view.findViewById<TextView>(R.id.tv_1percent_low)
val fps01PercentText = view.findViewById<TextView>(R.id.tv_01percent_low)
// Set session info
sessionInfoText.text = buildString {
appendLine("📅 ${analyticsData.sessionDate}")
appendLine("⏱️ ${analyticsData.sessionDuration}")
appendLine("📊 ${analyticsData.totalSamples} samples")
append("📁 $logFileName")
}
// Set FPS statistics
maxFpsText.text = String.format("Max FPS: %.1f", analyticsData.fpsStats.maxFps)
minFpsText.text = String.format("Min FPS: %.1f", analyticsData.fpsStats.minFps)
avgFpsText.text = String.format("Avg FPS: %.1f", analyticsData.fpsStats.avgFps)
varianceText.text = String.format("Variance: %.2f", analyticsData.fpsStats.variance)
stdDevText.text = String.format("Std Dev: %.2f", analyticsData.fpsStats.standardDeviation)
smoothnessText.text = String.format("Smoothness: %.1f%%", analyticsData.fpsStats.smoothnessPercentage)
fps1PercentText.text = String.format("1%% Low: %.1f FPS", analyticsData.fpsStats.fps1PercentLow)
fps01PercentText.text = String.format("0.1%% Low: %.1f FPS", analyticsData.fpsStats.fps0_1PercentLow)
// Set FPS graph data
fpsGraphView.setData(
analyticsData.fpsTimeData,
analyticsData.fpsStats.avgFps,
analyticsData.fpsStats.fps1PercentLow
)
// Set Frame Time graph data
val avgFrameTime = if (analyticsData.fpsStats.avgFps > 0) {
1000.0 / analyticsData.fpsStats.avgFps
} else {
0.0
}
frameTimeGraphView.setData(analyticsData.frameTimeData, avgFrameTime)
// Get CPU graph views
val cpuUsageGraphView = view.findViewById<CpuGraphView>(R.id.cpu_usage_graph_view)
val cpuTempGraphView = view.findViewById<CpuTempGraphView>(R.id.cpu_temp_graph_view)
val cpuClockGraphView = view.findViewById<CpuClockGraphView>(R.id.cpu_clock_graph_view)
// Get CPU statistics views
val maxCpuUsageText = view.findViewById<TextView>(R.id.tv_max_cpu_usage)
val minCpuUsageText = view.findViewById<TextView>(R.id.tv_min_cpu_usage)
val avgCpuUsageText = view.findViewById<TextView>(R.id.tv_avg_cpu_usage)
val maxCpuTempText = view.findViewById<TextView>(R.id.tv_max_cpu_temp)
val minCpuTempText = view.findViewById<TextView>(R.id.tv_min_cpu_temp)
val avgCpuTempText = view.findViewById<TextView>(R.id.tv_avg_cpu_temp)
// Set CPU graph data
cpuUsageGraphView.setData(analyticsData.cpuUsageTimeData, analyticsData.cpuStats.avgUsage)
cpuTempGraphView.setData(analyticsData.cpuTempTimeData, analyticsData.cpuStats.avgTemp)
cpuClockGraphView.setData(analyticsData.cpuClockTimeData)
// Set CPU statistics
maxCpuUsageText.text = String.format("Max Usage: %.0f%%", analyticsData.cpuStats.maxUsage)
minCpuUsageText.text = String.format("Min Usage: %.0f%%", analyticsData.cpuStats.minUsage)
avgCpuUsageText.text = String.format("Avg Usage: %.1f%%", analyticsData.cpuStats.avgUsage)
maxCpuTempText.text = String.format("Max Temp: %.1f°C", analyticsData.cpuStats.maxTemp)
minCpuTempText.text = String.format("Min Temp: %.1f°C", analyticsData.cpuStats.minTemp)
avgCpuTempText.text = String.format("Avg Temp: %.1f°C", analyticsData.cpuStats.avgTemp)
// Get GPU graph views
val gpuUsageGraphView = view.findViewById<GpuUsageGraphView>(R.id.gpu_usage_graph_view)
val gpuTempGraphView = view.findViewById<GpuTempGraphView>(R.id.gpu_temp_graph_view)
val gpuClockGraphView = view.findViewById<GpuClockGraphView>(R.id.gpu_clock_graph_view)
// Get GPU statistics views
val maxGpuUsageText = view.findViewById<TextView>(R.id.tv_max_gpu_usage)
val minGpuUsageText = view.findViewById<TextView>(R.id.tv_min_gpu_usage)
val avgGpuUsageText = view.findViewById<TextView>(R.id.tv_avg_gpu_usage)
val maxGpuClockText = view.findViewById<TextView>(R.id.tv_max_gpu_clock)
val minGpuClockText = view.findViewById<TextView>(R.id.tv_min_gpu_clock)
val avgGpuClockText = view.findViewById<TextView>(R.id.tv_avg_gpu_clock)
val maxGpuTempText = view.findViewById<TextView>(R.id.tv_max_gpu_temp)
val minGpuTempText = view.findViewById<TextView>(R.id.tv_min_gpu_temp)
val avgGpuTempText = view.findViewById<TextView>(R.id.tv_avg_gpu_temp)
// Set GPU graph data
gpuUsageGraphView.setData(analyticsData.gpuUsageTimeData, analyticsData.gpuStats.avgUsage)
gpuTempGraphView.setData(analyticsData.gpuTempTimeData, analyticsData.gpuStats.avgTemp)
gpuClockGraphView.setData(analyticsData.gpuClockTimeData, analyticsData.gpuStats.avgClock)
// Set GPU statistics
maxGpuUsageText.text = String.format("Max Usage: %.0f%%", analyticsData.gpuStats.maxUsage)
minGpuUsageText.text = String.format("Min Usage: %.0f%%", analyticsData.gpuStats.minUsage)
avgGpuUsageText.text = String.format("Avg Usage: %.1f%%", analyticsData.gpuStats.avgUsage)
maxGpuClockText.text = String.format("Max Clock: %.0f MHz", analyticsData.gpuStats.maxClock)
minGpuClockText.text = String.format("Min Clock: %.0f MHz", analyticsData.gpuStats.minClock)
avgGpuClockText.text = String.format("Avg Clock: %.0f MHz", analyticsData.gpuStats.avgClock)
maxGpuTempText.text = String.format("Max Temp: %.1f°C", analyticsData.gpuStats.maxTemp)
minGpuTempText.text = String.format("Min Temp: %.1f°C", analyticsData.gpuStats.minTemp)
avgGpuTempText.text = String.format("Avg Temp: %.1f°C", analyticsData.gpuStats.avgTemp)
}
}

View File

@@ -0,0 +1,46 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.android.gamebar.R
class LogHistoryAdapter(
private val logFiles: List<GameBarLogFragment.LogFile>,
private val onItemClick: (GameBarLogFragment.LogFile, View) -> Unit
) : RecyclerView.Adapter<LogHistoryAdapter.LogFileViewHolder>() {
inner class LogFileViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val fileName: TextView = itemView.findViewById(R.id.tv_file_name)
private val fileInfo: TextView = itemView.findViewById(R.id.tv_file_info)
fun bind(logFile: GameBarLogFragment.LogFile) {
fileName.text = logFile.name
fileInfo.text = "${logFile.size}${logFile.lastModified}"
itemView.setOnClickListener {
onItemClick(logFile, itemView)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogFileViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_log_file, parent, false)
return LogFileViewHolder(view)
}
override fun onBindViewHolder(holder: LogFileViewHolder, position: Int) {
holder.bind(logFiles[position])
}
override fun getItemCount(): Int = logFiles.size
}

View File

@@ -0,0 +1,120 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.Switch
import android.widget.TextView
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import com.android.gamebar.R
class PerAppLogAdapter(
private val context: Context,
private var apps: List<ApplicationInfo>,
private val onSwitchChanged: (String, Boolean) -> Unit,
private val onViewLogsClicked: (String, String) -> Unit // packageName, appName
) : RecyclerView.Adapter<PerAppLogAdapter.PerAppLogViewHolder>() {
private val packageManager = context.packageManager
private val perAppLogManager = PerAppLogManager.getInstance()
private var enabledApps = setOf<String>()
private var currentlyLoggingApps = setOf<String>()
fun updateApps(newApps: List<ApplicationInfo>) {
apps = newApps
notifyDataSetChanged()
}
fun updateEnabledApps(enabled: Set<String>) {
enabledApps = enabled
notifyDataSetChanged()
}
fun updateCurrentlyLoggingApps(logging: Set<String>) {
currentlyLoggingApps = logging
notifyDataSetChanged()
}
fun refreshLogFileStates() {
// Force refresh of the adapter to update log file states
notifyDataSetChanged()
}
inner class PerAppLogViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val appIcon: ImageView = itemView.findViewById(R.id.iv_app_icon)
private val appName: TextView = itemView.findViewById(R.id.tv_app_name)
private val packageName: TextView = itemView.findViewById(R.id.tv_package_name)
private val viewLogsIcon: ImageView = itemView.findViewById(R.id.iv_view_logs)
private val loggingSwitch: Switch = itemView.findViewById(R.id.switch_per_app_logging)
fun bind(app: ApplicationInfo) {
val appLabel = app.loadLabel(packageManager).toString()
appIcon.setImageDrawable(app.loadIcon(packageManager))
appName.text = appLabel
packageName.text = app.packageName
// Update switch state
loggingSwitch.setOnCheckedChangeListener(null) // Prevent recursive calls
loggingSwitch.isChecked = enabledApps.contains(app.packageName)
// Add visual indicator if currently logging
if (currentlyLoggingApps.contains(app.packageName)) {
appName.setTextColor(context.getColor(R.color.gamebar_green))
packageName.text = "${app.packageName} • LOGGING"
} else {
appName.setTextColor(context.getColor(R.color.app_name_text_selector))
packageName.text = app.packageName
}
// Set switch listener
loggingSwitch.setOnCheckedChangeListener { _, isChecked ->
onSwitchChanged(app.packageName, isChecked)
}
// Always enable view logs icon - the log view will handle empty state
viewLogsIcon.alpha = 1.0f
viewLogsIcon.isEnabled = true
// Set view logs click listener - always navigate to log view
viewLogsIcon.setOnClickListener {
onViewLogsClicked(app.packageName, appLabel)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PerAppLogViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_per_app_log, parent, false)
return PerAppLogViewHolder(view)
}
override fun onBindViewHolder(holder: PerAppLogViewHolder, position: Int) {
holder.bind(apps[position])
}
override fun getItemCount(): Int = apps.size
fun filter(query: String): List<ApplicationInfo> {
return if (query.isEmpty()) {
apps
} else {
apps.filter { app ->
val label = app.loadLabel(packageManager).toString().lowercase()
val pkg = app.packageName.lowercase()
label.contains(query.lowercase()) || pkg.contains(query.lowercase())
}
}
}
}

View File

@@ -0,0 +1,407 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.content.Context
import android.content.SharedPreferences
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.preference.PreferenceManager
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.ConcurrentHashMap
class PerAppLogManager private constructor() {
interface PerAppStateListener {
fun onAppLoggingStarted(packageName: String)
fun onAppLoggingStopped(packageName: String)
}
companion object {
@Volatile
private var INSTANCE: PerAppLogManager? = null
private const val TAG = "PerAppLogManager"
private const val PREF_PER_APP_ENABLED_APPS = "per_app_logging_enabled_apps"
private const val MAX_ROWS_PER_APP = 5000
@JvmStatic
@Synchronized
fun getInstance(): PerAppLogManager {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: PerAppLogManager().also { INSTANCE = it }
}
}
private val CSV_HEADER = arrayOf(
"DateTime",
"PackageName",
"FPS",
"Frame_Time",
"Battery_Temp",
"CPU_Usage",
"CPU_Clock",
"CPU_Temp",
"RAM_Usage",
"RAM_Speed",
"RAM_Temp",
"GPU_Usage",
"GPU_Clock",
"GPU_Temp"
)
}
private val activeLogSessions = ConcurrentHashMap<String, MutableList<Array<String>>>()
private val handler = Handler(Looper.getMainLooper())
private var listener: PerAppStateListener? = null
private var currentForegroundApp: String? = null
fun setPerAppStateListener(listener: PerAppStateListener?) {
this.listener = listener
}
fun getEnabledApps(context: Context): Set<String> {
return PreferenceManager.getDefaultSharedPreferences(context)
.getStringSet(PREF_PER_APP_ENABLED_APPS, emptySet()) ?: emptySet()
}
fun setAppLoggingEnabled(context: Context, packageName: String, enabled: Boolean) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val current = prefs.getStringSet(PREF_PER_APP_ENABLED_APPS, emptySet())?.toMutableSet() ?: mutableSetOf()
if (enabled) {
current.add(packageName)
} else {
current.remove(packageName)
// Stop logging for this app if it's currently active
stopLoggingForApp(packageName)
}
prefs.edit().putStringSet(PREF_PER_APP_ENABLED_APPS, current).apply()
}
fun isAppLoggingEnabled(context: Context, packageName: String): Boolean {
return getEnabledApps(context).contains(packageName)
}
fun onAppBecameForeground(context: Context, packageName: String) {
if (currentForegroundApp == packageName) return
// Stop logging for previous app if it was being logged
currentForegroundApp?.let { previousApp ->
if (isAppLoggingActive(previousApp)) {
stopLoggingForApp(previousApp)
}
}
currentForegroundApp = packageName
// Start logging for new app if enabled AND per-app logging mode is active
// Note: Per-app logging works independently of GameBar overlay visibility
val gameDataExport = GameDataExport.getInstance()
if (isAppLoggingEnabled(context, packageName) &&
gameDataExport.getLoggingMode() == GameDataExport.LoggingMode.PER_APP &&
gameDataExport.isCapturing()) {
startLoggingForApp(packageName)
}
}
fun onAppWentToBackground(context: Context, packageName: String) {
if (currentForegroundApp == packageName) {
currentForegroundApp = null
if (isAppLoggingActive(packageName)) {
stopLoggingForApp(packageName)
}
}
}
private fun startLoggingForApp(packageName: String) {
if (activeLogSessions.containsKey(packageName)) return
// Check if GameBar overlay is actually showing
val isGameBarShowing = GameBar.isShowing()
if (!isGameBarShowing) {
// GameBar overlay is OFF - cannot collect data
Log.w(TAG, "Cannot start logging for $packageName - GameBar overlay is OFF")
handler.post {
try {
val context = android.app.ActivityThread.currentApplication()
context?.let {
val pm = it.packageManager
try {
val appInfo = pm.getApplicationInfo(packageName, 0)
val appName = pm.getApplicationLabel(appInfo).toString()
android.widget.Toast.makeText(it,
"$appName: GameBar logging enabled but GameBar overlay is OFF. Turn ON GameBar to collect logs.",
android.widget.Toast.LENGTH_LONG).show()
} catch (e: Exception) {
android.widget.Toast.makeText(it,
"GameBar logging enabled but GameBar overlay is OFF. Turn ON GameBar to collect logs.",
android.widget.Toast.LENGTH_LONG).show()
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to show warning toast for $packageName", e)
}
}
return
}
Log.d(TAG, "Starting logging for app: $packageName")
val logData = mutableListOf<Array<String>>()
logData.add(CSV_HEADER)
activeLogSessions[packageName] = logData
listener?.onAppLoggingStarted(packageName)
// Show toast notification
handler.post {
try {
val context = android.app.ActivityThread.currentApplication()
context?.let {
val pm = it.packageManager
try {
val appInfo = pm.getApplicationInfo(packageName, 0)
val appName = pm.getApplicationLabel(appInfo).toString()
android.widget.Toast.makeText(it, "$appName GameBar log started", android.widget.Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
android.widget.Toast.makeText(it, "$packageName GameBar log started", android.widget.Toast.LENGTH_SHORT).show()
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to show start toast for $packageName", e)
}
}
}
private fun stopLoggingForApp(packageName: String) {
val logData = activeLogSessions.remove(packageName)
if (logData != null && logData.size > 1) {
Log.d(TAG, "Stopping logging for app: $packageName")
exportPerAppDataToCsv(packageName, logData)
listener?.onAppLoggingStopped(packageName)
// Show toast notification
handler.post {
try {
val context = android.app.ActivityThread.currentApplication()
context?.let {
val pm = it.packageManager
try {
val appInfo = pm.getApplicationInfo(packageName, 0)
val appName = pm.getApplicationLabel(appInfo).toString()
android.widget.Toast.makeText(it, "$appName GameBar log ended", android.widget.Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
android.widget.Toast.makeText(it, "$packageName GameBar log ended", android.widget.Toast.LENGTH_SHORT).show()
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to show stop toast for $packageName", e)
}
}
}
}
fun isAppLoggingActive(packageName: String): Boolean {
return activeLogSessions.containsKey(packageName)
}
fun addPerAppData(
packageName: String,
dateTime: String,
fps: String,
frameTime: String,
batteryTemp: String,
cpuUsage: String,
cpuClock: String,
cpuTemp: String,
ramUsage: String,
ramSpeed: String,
ramTemp: String,
gpuUsage: String,
gpuClock: String,
gpuTemp: String
) {
val logData = activeLogSessions[packageName] ?: return
// Prevent unlimited memory growth
if (logData.size >= MAX_ROWS_PER_APP) {
// Remove oldest entries but keep header
if (logData.size > 1) {
val toRemove = logData.size / 2
repeat(toRemove) {
if (logData.size > 1) {
logData.removeAt(1)
}
}
}
}
val row = arrayOf(
dateTime,
packageName,
fps,
frameTime,
batteryTemp,
cpuUsage,
cpuClock,
cpuTemp,
ramUsage,
ramSpeed,
ramTemp,
gpuUsage,
gpuClock,
gpuTemp
)
logData.add(row)
}
private fun exportPerAppDataToCsv(packageName: String, logData: List<Array<String>>) {
if (logData.size <= 1) return
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val fileName = "${packageName}_GameBar_log_$timeStamp.csv"
val outFile = File(Environment.getExternalStorageDirectory(), fileName)
var bw: BufferedWriter? = null
try {
bw = BufferedWriter(FileWriter(outFile, false))
for (row in logData) {
bw.write(toCsvLine(row))
bw.newLine()
}
bw.flush()
Log.d(TAG, "Exported per-app log for $packageName to ${outFile.absolutePath}")
} catch (e: IOException) {
Log.e(TAG, "Failed to export per-app log for $packageName", e)
} finally {
bw?.let {
try { it.close() } catch (ignored: IOException) {}
}
}
}
private fun toCsvLine(columns: Array<String>): String {
return columns.joinToString(",")
}
fun getPerAppLogFiles(packageName: String): List<File> {
val externalStorageDir = Environment.getExternalStorageDirectory()
val files = externalStorageDir.listFiles { file ->
file.name.startsWith("${packageName}_GameBar_log_") && file.name.endsWith(".csv")
}
return files?.sortedByDescending { it.lastModified() } ?: emptyList()
}
fun getAllPerAppLogFiles(): Map<String, List<File>> {
val externalStorageDir = Environment.getExternalStorageDirectory()
val files = externalStorageDir.listFiles { file ->
file.name.contains("_GameBar_log_") &&
file.name.endsWith(".csv") &&
!file.name.startsWith("GameBar_log_") // Exclude global logs
} ?: emptyArray()
return files.groupBy { file ->
// Extract package name from filename: packageName_GameBar_log_timestamp.csv
val fileName = file.name
val endIndex = fileName.indexOf("_GameBar_log_")
if (endIndex > 0) fileName.substring(0, endIndex) else "unknown"
}.mapValues { (_, fileList) ->
fileList.sortedByDescending { it.lastModified() }
}
}
fun getCurrentlyLoggingApps(): Set<String> {
return activeLogSessions.keys.toSet()
}
/**
* Start manual logging for an app via double-tap (even if not in enabled apps list)
*/
fun startManualLoggingForApp(packageName: String) {
if (activeLogSessions.containsKey(packageName)) {
// Already logging, ignore
return
}
// Check if GameBar overlay is showing
val isGameBarShowing = GameBar.isShowing()
if (!isGameBarShowing) {
handler.post {
try {
val context = android.app.ActivityThread.currentApplication()
context?.let {
android.widget.Toast.makeText(it,
"Cannot start logging - GameBar overlay is OFF",
android.widget.Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to show toast", e)
}
}
return
}
Log.d(TAG, "Starting manual logging for app: $packageName")
val logData = mutableListOf<Array<String>>()
logData.add(CSV_HEADER)
activeLogSessions[packageName] = logData
listener?.onAppLoggingStarted(packageName)
// Show toast notification
handler.post {
try {
val context = android.app.ActivityThread.currentApplication()
context?.let {
val pm = it.packageManager
try {
val appInfo = pm.getApplicationInfo(packageName, 0)
val appName = pm.getApplicationLabel(appInfo).toString()
android.widget.Toast.makeText(it, "$appName: Manual logging started", android.widget.Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
android.widget.Toast.makeText(it, "Manual logging started for $packageName", android.widget.Toast.LENGTH_SHORT).show()
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to show start toast for $packageName", e)
}
}
}
/**
* Stop manual logging for an app via double-tap
*/
fun stopManualLoggingForApp(packageName: String) {
stopLoggingForApp(packageName)
}
fun stopAllPerAppLogging() {
val appsToStop = activeLogSessions.keys.toList()
appsToStop.forEach { packageName ->
stopLoggingForApp(packageName)
}
}
fun handlePerAppLoggingStarted(context: Context) {
// When per-app logging is started, check if current foreground app should be logged
val foregroundApp = ForegroundAppDetector.getForegroundPackageName(context)
if (foregroundApp != "Unknown" && foregroundApp.isNotEmpty()) {
currentForegroundApp = foregroundApp
if (isAppLoggingEnabled(context, foregroundApp)) {
startLoggingForApp(foregroundApp)
}
}
}
}

View File

@@ -0,0 +1,554 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.util.Log
import java.io.BufferedReader
import java.io.File
import java.io.FileReader
import java.io.Serializable
import kotlin.math.pow
import kotlin.math.sqrt
data class LogAnalytics(
val fpsStats: FpsStatistics,
val cpuStats: CpuStatistics,
val gpuStats: GpuStatistics,
val sessionDuration: String,
val totalSamples: Int,
val appName: String,
val sessionDate: String,
val fpsTimeData: List<Pair<Long, Double>>, // Timestamp in millis, FPS value
val frameTimeData: List<Pair<Long, Double>>, // Frame time over time
val cpuUsageTimeData: List<Pair<Long, Double>>, // CPU usage over time
val cpuTempTimeData: List<Pair<Long, Double>>, // CPU temp over time
val cpuClockTimeData: Map<Int, List<Pair<Long, Double>>>, // Per-core clock speeds over time
val gpuUsageTimeData: List<Pair<Long, Double>>, // GPU usage over time
val gpuTempTimeData: List<Pair<Long, Double>>, // GPU temp over time
val gpuClockTimeData: List<Pair<Long, Double>> // GPU clock speed over time
) : Serializable
data class FpsStatistics(
val maxFps: Double,
val minFps: Double,
val avgFps: Double,
val variance: Double,
val standardDeviation: Double,
val fps1PercentLow: Double, // 1% low - worst 1% of frames
val fps0_1PercentLow: Double, // 0.1% low - worst 0.1% of frames
val smoothnessPercentage: Double // Percentage of frames >= 45 FPS
) : Serializable
data class CpuStatistics(
val maxUsage: Double,
val minUsage: Double,
val avgUsage: Double,
val maxTemp: Double,
val minTemp: Double,
val avgTemp: Double
) : Serializable
data class GpuStatistics(
val maxUsage: Double,
val minUsage: Double,
val avgUsage: Double,
val maxClock: Double,
val minClock: Double,
val avgClock: Double,
val maxTemp: Double,
val minTemp: Double,
val avgTemp: Double
) : Serializable
class PerAppLogReader {
companion object {
private const val TAG = "PerAppLogReader"
// CSV column indices (updated format)
private const val COL_DATETIME = 0
private const val COL_PACKAGE_NAME = 1
private const val COL_FPS = 2
private const val COL_FRAME_TIME = 3
private const val COL_BATTERY_TEMP = 4
private const val COL_CPU_USAGE = 5
private const val COL_CPU_CLOCK = 6
private const val COL_CPU_TEMP = 7
private const val COL_RAM_USAGE = 8
private const val COL_RAM_SPEED = 9
private const val COL_RAM_TEMP = 10
private const val COL_GPU_USAGE = 11
private const val COL_GPU_CLOCK = 12
private const val COL_GPU_TEMP = 13
}
/**
* Read and analyze a log file
*/
fun analyzeLogFile(logFilePath: String): LogAnalytics? {
return try {
val file = File(logFilePath)
if (!file.exists() || !file.canRead()) {
Log.e(TAG, "Log file does not exist or cannot be read: $logFilePath")
return null
}
val fpsValues = mutableListOf<Double>()
val fpsTimeData = mutableListOf<Pair<Long, Double>>()
val frameTimeValues = mutableListOf<Double>()
val frameTimeData = mutableListOf<Pair<Long, Double>>()
val cpuUsageValues = mutableListOf<Double>()
val cpuUsageTimeData = mutableListOf<Pair<Long, Double>>()
val cpuTempValues = mutableListOf<Double>()
val cpuTempTimeData = mutableListOf<Pair<Long, Double>>()
val cpuClockTimeData = mutableMapOf<Int, MutableList<Pair<Long, Double>>>()
val gpuUsageValues = mutableListOf<Double>()
val gpuUsageTimeData = mutableListOf<Pair<Long, Double>>()
val gpuClockValues = mutableListOf<Double>()
val gpuClockTimeData = mutableListOf<Pair<Long, Double>>()
val gpuTempValues = mutableListOf<Double>()
val gpuTempTimeData = mutableListOf<Pair<Long, Double>>()
var firstTimestamp: String? = null
var lastTimestamp: String? = null
var packageName = ""
var lineCount = 0
var sessionStartTimeMs: Long = 0
BufferedReader(FileReader(file)).use { reader ->
var line = reader.readLine()
// Skip header
if (line != null && line.contains("DateTime")) {
line = reader.readLine()
}
// Read data lines
while (line != null) {
lineCount++
val columns = line.split(",")
if (columns.size >= COL_FPS + 1) {
try {
// Extract timestamp
val timestampStr = columns[COL_DATETIME].trim()
// Extract FPS
val fpsStr = columns[COL_FPS].trim()
if (fpsStr.isNotEmpty() && fpsStr != "N/A" && fpsStr != "-") {
val fps = fpsStr.toDoubleOrNull()
if (fps != null && fps > 0) {
fpsValues.add(fps)
// Parse timestamp and calculate relative time
try {
val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault())
val timestamp = dateFormat.parse(timestampStr)?.time ?: 0L
if (sessionStartTimeMs == 0L) {
sessionStartTimeMs = timestamp
}
// Store relative time in milliseconds
val relativeTime = timestamp - sessionStartTimeMs
fpsTimeData.add(Pair(relativeTime, fps))
} catch (e: Exception) {
// If timestamp parsing fails, use sequential time
fpsTimeData.add(Pair(lineCount * 1000L, fps))
}
}
}
// Extract Frame Time
if (columns.size > COL_FRAME_TIME) {
val frameTimeStr = columns[COL_FRAME_TIME].trim()
if (frameTimeStr.isNotEmpty() && frameTimeStr != "N/A" && frameTimeStr != "-") {
val frameTime = frameTimeStr.toDoubleOrNull()
if (frameTime != null && frameTime > 0) {
frameTimeValues.add(frameTime)
val relativeTime = if (sessionStartTimeMs > 0) {
fpsTimeData.lastOrNull()?.first ?: (lineCount * 1000L)
} else {
lineCount * 1000L
}
frameTimeData.add(Pair(relativeTime, frameTime))
}
}
}
// Extract CPU Usage
if (columns.size > COL_CPU_USAGE) {
val cpuUsageStr = columns[COL_CPU_USAGE].trim()
if (cpuUsageStr.isNotEmpty() && cpuUsageStr != "N/A") {
val cpuUsage = cpuUsageStr.toDoubleOrNull()
if (cpuUsage != null && cpuUsage >= 0) {
cpuUsageValues.add(cpuUsage)
val relativeTime = if (sessionStartTimeMs > 0) {
fpsTimeData.lastOrNull()?.first ?: (lineCount * 1000L)
} else {
lineCount * 1000L
}
cpuUsageTimeData.add(Pair(relativeTime, cpuUsage))
}
}
}
// Extract CPU Temp
if (columns.size > COL_CPU_TEMP) {
val cpuTempStr = columns[COL_CPU_TEMP].trim()
if (cpuTempStr.isNotEmpty() && cpuTempStr != "N/A") {
val cpuTemp = cpuTempStr.toDoubleOrNull()
if (cpuTemp != null && cpuTemp > 0) {
cpuTempValues.add(cpuTemp)
val relativeTime = if (sessionStartTimeMs > 0) {
fpsTimeData.lastOrNull()?.first ?: (lineCount * 1000L)
} else {
lineCount * 1000L
}
cpuTempTimeData.add(Pair(relativeTime, cpuTemp))
}
}
}
// Extract CPU Clock (multi-core data)
if (columns.size > COL_CPU_CLOCK) {
val cpuClockStr = columns[COL_CPU_CLOCK].trim()
if (cpuClockStr.isNotEmpty() && cpuClockStr != "N/A") {
// Parse format: "cpu0: 1800 MHz; cpu1: 2000 MHz; ..."
val relativeTime = if (sessionStartTimeMs > 0) {
fpsTimeData.lastOrNull()?.first ?: (lineCount * 1000L)
} else {
lineCount * 1000L
}
cpuClockStr.split(";").forEachIndexed { index, coreData ->
try {
// Extract MHz value from "cpuX: YYYY MHz"
val mhzMatch = Regex("(\\d+)\\s*MHz").find(coreData)
if (mhzMatch != null) {
val mhz = mhzMatch.groupValues[1].toDouble()
if (!cpuClockTimeData.containsKey(index)) {
cpuClockTimeData[index] = mutableListOf()
}
cpuClockTimeData[index]?.add(Pair(relativeTime, mhz))
}
} catch (e: Exception) {
// Skip malformed core data
}
}
}
}
// Extract GPU Usage
if (columns.size > COL_GPU_USAGE) {
val gpuUsageStr = columns[COL_GPU_USAGE].trim()
if (gpuUsageStr.isNotEmpty() && gpuUsageStr != "N/A") {
val gpuUsage = gpuUsageStr.toDoubleOrNull()
if (gpuUsage != null && gpuUsage >= 0) {
gpuUsageValues.add(gpuUsage)
val relativeTime = if (sessionStartTimeMs > 0) {
fpsTimeData.lastOrNull()?.first ?: (lineCount * 1000L)
} else {
lineCount * 1000L
}
gpuUsageTimeData.add(Pair(relativeTime, gpuUsage))
}
}
}
// Extract GPU Clock
if (columns.size > COL_GPU_CLOCK) {
val gpuClockStr = columns[COL_GPU_CLOCK].trim()
if (gpuClockStr.isNotEmpty() && gpuClockStr != "N/A") {
val gpuClock = gpuClockStr.toDoubleOrNull()
if (gpuClock != null && gpuClock > 0) {
gpuClockValues.add(gpuClock)
val relativeTime = if (sessionStartTimeMs > 0) {
fpsTimeData.lastOrNull()?.first ?: (lineCount * 1000L)
} else {
lineCount * 1000L
}
gpuClockTimeData.add(Pair(relativeTime, gpuClock))
}
}
}
// Extract GPU Temp
if (columns.size > COL_GPU_TEMP) {
val gpuTempStr = columns[COL_GPU_TEMP].trim()
if (gpuTempStr.isNotEmpty() && gpuTempStr != "N/A") {
val gpuTemp = gpuTempStr.toDoubleOrNull()
if (gpuTemp != null && gpuTemp > 0) {
gpuTempValues.add(gpuTemp)
val relativeTime = if (sessionStartTimeMs > 0) {
fpsTimeData.lastOrNull()?.first ?: (lineCount * 1000L)
} else {
lineCount * 1000L
}
gpuTempTimeData.add(Pair(relativeTime, gpuTemp))
}
}
}
// Extract timestamps for session duration
if (firstTimestamp == null) {
firstTimestamp = timestampStr
packageName = columns[COL_PACKAGE_NAME].trim()
}
lastTimestamp = timestampStr
} catch (e: Exception) {
Log.w(TAG, "Error parsing line $lineCount: $line", e)
}
}
line = reader.readLine()
}
}
if (fpsValues.isEmpty()) {
Log.w(TAG, "No valid FPS data found in log file")
return null
}
// Calculate FPS statistics
val fpsStats = calculateFpsStatistics(fpsValues)
// Calculate CPU statistics
val cpuStats = calculateCpuStatistics(cpuUsageValues, cpuTempValues)
// Calculate GPU statistics
val gpuStats = calculateGpuStatistics(gpuUsageValues, gpuClockValues, gpuTempValues)
// Calculate session duration
val sessionDuration = calculateSessionDuration(firstTimestamp, lastTimestamp)
// Extract session date from filename
val sessionDate = extractSessionDate(file.name)
LogAnalytics(
fpsStats = fpsStats,
cpuStats = cpuStats,
gpuStats = gpuStats,
sessionDuration = sessionDuration,
totalSamples = fpsValues.size,
appName = packageName,
sessionDate = sessionDate,
fpsTimeData = fpsTimeData,
frameTimeData = frameTimeData,
cpuUsageTimeData = cpuUsageTimeData,
cpuTempTimeData = cpuTempTimeData,
cpuClockTimeData = cpuClockTimeData,
gpuUsageTimeData = gpuUsageTimeData,
gpuTempTimeData = gpuTempTimeData,
gpuClockTimeData = gpuClockTimeData
)
} catch (e: Exception) {
Log.e(TAG, "Error analyzing log file: $logFilePath", e)
null
}
}
/**
* Calculate comprehensive FPS statistics
*/
private fun calculateFpsStatistics(fpsValues: List<Double>): FpsStatistics {
if (fpsValues.isEmpty()) {
return FpsStatistics(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
}
val sortedFps = fpsValues.sorted()
// Max and Min
val maxFps = sortedFps.last()
val minFps = sortedFps.first()
// Average
val avgFps = fpsValues.average()
// Variance and Standard Deviation
val variance = if (fpsValues.size > 1) {
fpsValues.map { (it - avgFps).pow(2) }.sum() / fpsValues.size
} else {
0.0
}
val stdDev = sqrt(variance)
// Calculate 1% low and 0.1% low
val fps1PercentLow = calculatePercentileLow(sortedFps, 0.01)
val fps0_1PercentLow = calculatePercentileLow(sortedFps, 0.001)
// Calculate smoothness percentage (frames >= 45 FPS)
val smoothFrames = fpsValues.count { it >= 45.0 }
val smoothnessPercentage = (smoothFrames.toDouble() / fpsValues.size) * 100.0
return FpsStatistics(
maxFps = maxFps,
minFps = minFps,
avgFps = avgFps,
variance = variance,
standardDeviation = stdDev,
fps1PercentLow = fps1PercentLow,
fps0_1PercentLow = fps0_1PercentLow,
smoothnessPercentage = smoothnessPercentage
)
}
/**
* Calculate CPU statistics
*/
private fun calculateCpuStatistics(cpuUsageValues: List<Double>, cpuTempValues: List<Double>): CpuStatistics {
val maxUsage = if (cpuUsageValues.isNotEmpty()) cpuUsageValues.maxOrNull() ?: 0.0 else 0.0
val minUsage = if (cpuUsageValues.isNotEmpty()) cpuUsageValues.minOrNull() ?: 0.0 else 0.0
val avgUsage = if (cpuUsageValues.isNotEmpty()) cpuUsageValues.average() else 0.0
val maxTemp = if (cpuTempValues.isNotEmpty()) cpuTempValues.maxOrNull() ?: 0.0 else 0.0
val minTemp = if (cpuTempValues.isNotEmpty()) cpuTempValues.minOrNull() ?: 0.0 else 0.0
val avgTemp = if (cpuTempValues.isNotEmpty()) cpuTempValues.average() else 0.0
return CpuStatistics(
maxUsage = maxUsage,
minUsage = minUsage,
avgUsage = avgUsage,
maxTemp = maxTemp,
minTemp = minTemp,
avgTemp = avgTemp
)
}
/**
* Calculate GPU statistics
*/
private fun calculateGpuStatistics(
gpuUsageValues: List<Double>,
gpuClockValues: List<Double>,
gpuTempValues: List<Double>
): GpuStatistics {
val maxUsage = if (gpuUsageValues.isNotEmpty()) gpuUsageValues.maxOrNull() ?: 0.0 else 0.0
val minUsage = if (gpuUsageValues.isNotEmpty()) gpuUsageValues.minOrNull() ?: 0.0 else 0.0
val avgUsage = if (gpuUsageValues.isNotEmpty()) gpuUsageValues.average() else 0.0
val maxClock = if (gpuClockValues.isNotEmpty()) gpuClockValues.maxOrNull() ?: 0.0 else 0.0
val minClock = if (gpuClockValues.isNotEmpty()) gpuClockValues.minOrNull() ?: 0.0 else 0.0
val avgClock = if (gpuClockValues.isNotEmpty()) gpuClockValues.average() else 0.0
val maxTemp = if (gpuTempValues.isNotEmpty()) gpuTempValues.maxOrNull() ?: 0.0 else 0.0
val minTemp = if (gpuTempValues.isNotEmpty()) gpuTempValues.minOrNull() ?: 0.0 else 0.0
val avgTemp = if (gpuTempValues.isNotEmpty()) gpuTempValues.average() else 0.0
return GpuStatistics(
maxUsage = maxUsage,
minUsage = minUsage,
avgUsage = avgUsage,
maxClock = maxClock,
minClock = minClock,
avgClock = avgClock,
maxTemp = maxTemp,
minTemp = minTemp,
avgTemp = avgTemp
)
}
/**
* Calculate percentile low (e.g., 1% low = average of worst 1% of frames)
*/
private fun calculatePercentileLow(sortedFps: List<Double>, percentile: Double): Double {
if (sortedFps.isEmpty()) return 0.0
val count = (sortedFps.size * percentile).toInt().coerceAtLeast(1)
val worstFrames = sortedFps.take(count)
return if (worstFrames.isNotEmpty()) {
worstFrames.average()
} else {
sortedFps.first()
}
}
/**
* Calculate session duration from timestamps
*/
private fun calculateSessionDuration(firstTimestamp: String?, lastTimestamp: String?): String {
if (firstTimestamp == null || lastTimestamp == null) {
return "Unknown"
}
return try {
// Parse timestamps - format example: "2025-01-15 14:32:45"
val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault())
val startTime = dateFormat.parse(firstTimestamp)
val endTime = dateFormat.parse(lastTimestamp)
if (startTime != null && endTime != null) {
val durationMs = endTime.time - startTime.time
val seconds = (durationMs / 1000) % 60
val minutes = (durationMs / (1000 * 60)) % 60
val hours = (durationMs / (1000 * 60 * 60))
when {
hours > 0 -> String.format("%dh %dm %ds", hours, minutes, seconds)
minutes > 0 -> String.format("%dm %ds", minutes, seconds)
else -> String.format("%ds", seconds)
}
} else {
"Unknown"
}
} catch (e: Exception) {
Log.w(TAG, "Error calculating session duration", e)
"Unknown"
}
}
/**
* Extract session date from filename
* Filename format: packageName_GameBar_log_yyyyMMdd_HHmmss.csv
*/
private fun extractSessionDate(fileName: String): String {
return try {
val pattern = Regex("_(\\d{8})_(\\d{6})\\.csv$")
val match = pattern.find(fileName)
if (match != null) {
val dateStr = match.groupValues[1]
val timeStr = match.groupValues[2]
// Parse: yyyyMMdd_HHmmss
val inputFormat = java.text.SimpleDateFormat("yyyyMMddHHmmss", java.util.Locale.getDefault())
val outputFormat = java.text.SimpleDateFormat("MMM dd, yyyy HH:mm", java.util.Locale.getDefault())
val date = inputFormat.parse(dateStr + timeStr)
if (date != null) {
outputFormat.format(date)
} else {
"Unknown Date"
}
} else {
"Unknown Date"
}
} catch (e: Exception) {
Log.w(TAG, "Error extracting session date from filename: $fileName", e)
"Unknown Date"
}
}
/**
* Format FPS statistics for display
*/
fun formatFpsStats(stats: FpsStatistics): String {
return buildString {
appendLine("FPS Statistics:")
appendLine("─────────────────────────")
appendLine(String.format("Max FPS: %.1f", stats.maxFps))
appendLine(String.format("Min FPS: %.1f", stats.minFps))
appendLine(String.format("Avg FPS: %.1f", stats.avgFps))
appendLine(String.format("Variance: %.2f", stats.variance))
appendLine(String.format("Std Dev: %.2f", stats.standardDeviation))
appendLine(String.format("1%% Low: %.1f", stats.fps1PercentLow))
appendLine(String.format("0.1%% Low: %.1f", stats.fps0_1PercentLow))
}
}
}

View File

@@ -0,0 +1,153 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.app.Service
import android.content.Intent
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.widget.Toast
import android.content.pm.PackageManager
import android.util.Log
/**
* Dedicated service for per-app logging that runs independently of GameBar display logic.
* This service only monitors foreground app changes for logging purposes and doesn't
* interfere with GameBar functionality.
*/
class PerAppLogService : Service() {
private var handler: Handler? = null
private var monitorRunnable: Runnable? = null
@Volatile
private var isRunning = false
private var lastForegroundApp = ""
private val perAppLogManager = PerAppLogManager.getInstance()
companion object {
private const val TAG = "PerAppLogService"
private const val MONITOR_INTERVAL = 1000L // 1 second for responsive logging
}
override fun onCreate() {
super.onCreate()
handler = Handler(Looper.getMainLooper())
monitorRunnable = object : Runnable {
override fun run() {
if (isRunning) {
monitorForPerAppLogging()
if (isRunning && handler != null) {
handler!!.postDelayed(this, MONITOR_INTERVAL)
}
}
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!isRunning) {
Log.d(TAG, "Starting per-app logging service")
isRunning = true
handler?.let { h ->
monitorRunnable?.let { r ->
h.post(r)
}
}
}
return START_STICKY
}
private fun monitorForPerAppLogging() {
try {
if (!isRunning) return
// Only run if per-app logging is active
val gameDataExport = GameDataExport.getInstance()
if (gameDataExport.getLoggingMode() != GameDataExport.LoggingMode.PER_APP ||
!gameDataExport.isCapturing()) {
// Per-app logging is not active, stop service
Log.d(TAG, "Per-app logging not active, stopping service")
stopSelf()
return
}
val foreground = ForegroundAppDetector.getForegroundPackageName(this)
// Only process if foreground app changed
if (foreground != lastForegroundApp) {
Log.d(TAG, "Foreground app changed from $lastForegroundApp to $foreground")
// Handle previous app
if (lastForegroundApp.isNotEmpty() && lastForegroundApp != "Unknown") {
if (perAppLogManager.isAppLoggingActive(lastForegroundApp)) {
Log.d(TAG, "Stopping logging for $lastForegroundApp")
perAppLogManager.onAppWentToBackground(this, lastForegroundApp)
}
}
// Handle new app
if (foreground.isNotEmpty() && foreground != "Unknown") {
if (perAppLogManager.isAppLoggingEnabled(this, foreground)) {
Log.d(TAG, "Starting logging for $foreground")
perAppLogManager.onAppBecameForeground(this, foreground)
}
}
lastForegroundApp = foreground
}
} catch (e: Exception) {
Log.e(TAG, "Error in monitorForPerAppLogging", e)
}
}
private fun getAppName(packageName: String): String {
return try {
val pm = packageManager
val appInfo = pm.getApplicationInfo(packageName, 0)
pm.getApplicationLabel(appInfo).toString()
} catch (e: Exception) {
packageName // Fallback to package name
}
}
private fun showToast(message: String) {
handler?.post {
try {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Log.w(TAG, "Failed to show toast: $message", e)
}
}
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "Stopping per-app logging service")
isRunning = false
handler?.let {
monitorRunnable?.let { runnable ->
it.removeCallbacks(runnable)
}
it.removeCallbacksAndMessages(null)
}
// Stop all active per-app logging sessions
perAppLogManager.stopAllPerAppLogging()
// Clear state variables
lastForegroundApp = ""
handler = null
monitorRunnable = null
}
}

View File

@@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.os.Bundle
import com.android.settingslib.collapsingtoolbar.CollapsingToolbarBaseActivity
import com.android.gamebar.R
class PerAppLogViewActivity : CollapsingToolbarBaseActivity() {
companion object {
const val EXTRA_PACKAGE_NAME = "package_name"
const val EXTRA_APP_NAME = "app_name"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_gamebar_log)
val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: ""
val appName = intent.getStringExtra(EXTRA_APP_NAME) ?: "Unknown App"
title = "Logs for $appName"
if (savedInstanceState == null) {
val fragment = PerAppLogViewFragment().apply {
arguments = Bundle().apply {
putString(EXTRA_PACKAGE_NAME, packageName)
putString(EXTRA_APP_NAME, appName)
}
}
supportFragmentManager.beginTransaction()
.replace(R.id.content_frame, fragment)
.commit()
}
}
}

View File

@@ -0,0 +1,573 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar
import android.app.AlertDialog
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.PopupMenu
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.android.gamebar.R
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
class PerAppLogViewFragment : Fragment() {
private lateinit var searchBar: EditText
private lateinit var logHistoryRecyclerView: RecyclerView
private lateinit var logHistoryAdapter: LogHistoryAdapter
private lateinit var emptyStateView: View
private lateinit var emptyMessageView: TextView
private val logFiles = mutableListOf<GameBarLogFragment.LogFile>()
private val allLogFiles = mutableListOf<GameBarLogFragment.LogFile>()
private val perAppLogManager = PerAppLogManager.getInstance()
private var packageName: String = ""
private var appName: String = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
packageName = arguments?.getString(PerAppLogViewActivity.EXTRA_PACKAGE_NAME) ?: ""
appName = arguments?.getString(PerAppLogViewActivity.EXTRA_APP_NAME) ?: "Unknown App"
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_per_app_log_view, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
try {
initViews(view)
setupRecyclerView()
setupSearchBar()
loadPerAppLogHistory()
} catch (e: Exception) {
Toast.makeText(requireContext(), "Error loading logs: ${e.message}", Toast.LENGTH_LONG).show()
e.printStackTrace()
}
}
private fun initViews(view: View) {
searchBar = view.findViewById(R.id.search_bar)
logHistoryRecyclerView = view.findViewById(R.id.rv_log_history)
emptyStateView = view.findViewById(R.id.empty_state)
emptyMessageView = view.findViewById(R.id.tv_empty_message)
}
private fun setupRecyclerView() {
logHistoryAdapter = LogHistoryAdapter(logFiles) { logFile, view ->
showLogFilePopupMenu(logFile, view)
}
logHistoryRecyclerView.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = logHistoryAdapter
}
}
private fun setupSearchBar() {
searchBar.hint = "Search $appName logs..."
searchBar.addTextChangedListener(object : android.text.TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
filterLogs(s.toString().lowercase())
}
override fun afterTextChanged(s: android.text.Editable?) {}
})
}
private fun filterLogs(query: String) {
logFiles.clear()
if (query.isEmpty()) {
logFiles.addAll(allLogFiles)
} else {
allLogFiles.forEach { logFile ->
if (logFile.name.lowercase().contains(query) ||
logFile.lastModified.lowercase().contains(query)) {
logFiles.add(logFile)
}
}
}
logHistoryAdapter.notifyDataSetChanged()
updateEmptyState()
}
private fun updateEmptyState() {
if (logFiles.isEmpty()) {
logHistoryRecyclerView.visibility = View.GONE
emptyStateView.visibility = View.VISIBLE
emptyMessageView.text = "No logs available for $appName"
} else {
logHistoryRecyclerView.visibility = View.VISIBLE
emptyStateView.visibility = View.GONE
}
}
private fun loadPerAppLogHistory() {
logFiles.clear()
allLogFiles.clear()
val files = perAppLogManager.getPerAppLogFiles(packageName)
for (file in files) {
val logFile = GameBarLogFragment.LogFile(
name = file.name,
path = file.absolutePath,
size = formatFileSize(file.length()),
lastModified = formatDate(file.lastModified())
)
allLogFiles.add(logFile)
logFiles.add(logFile)
}
logHistoryAdapter.notifyDataSetChanged()
updateEmptyState()
}
private fun showLogFilePopupMenu(logFile: GameBarLogFragment.LogFile, anchorView: View) {
val popupMenu = PopupMenu(requireContext(), anchorView)
popupMenu.menuInflater.inflate(R.menu.log_file_popup_menu, popupMenu.menu)
popupMenu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.menu_open -> {
openLogFile(logFile)
true
}
R.id.menu_share -> {
shareLogFile(logFile)
true
}
R.id.menu_export -> {
exportLogFile(logFile)
true
}
R.id.menu_delete -> {
deleteLogFile(logFile)
true
}
else -> false
}
}
popupMenu.show()
}
private fun openLogFile(logFile: GameBarLogFragment.LogFile) {
// Show analytics popup
showLogAnalyticsDialog(logFile)
}
private fun showLogAnalyticsDialog(logFile: GameBarLogFragment.LogFile) {
// Show loading message
val loadingDialog = AlertDialog.Builder(requireContext())
.setTitle("Analyzing Log...")
.setMessage("Please wait while we analyze the session data.")
.setCancelable(false)
.create()
loadingDialog.show()
// Analyze log file in background thread
Thread {
val logReader = PerAppLogReader()
val analytics = logReader.analyzeLogFile(logFile.path)
// Update UI on main thread
requireActivity().runOnUiThread {
loadingDialog.dismiss()
if (analytics != null) {
showAnalyticsResult(logFile, analytics)
} else {
Toast.makeText(requireContext(), "Failed to analyze log file", Toast.LENGTH_SHORT).show()
}
}
}.start()
}
private fun showAnalyticsResult(logFile: GameBarLogFragment.LogFile, analytics: LogAnalytics) {
// Inflate custom dialog layout
val dialogView = layoutInflater.inflate(R.layout.dialog_log_analytics, null)
// Get views
val sessionInfoText = dialogView.findViewById<TextView>(R.id.tv_session_info)
val fpsGraphView = dialogView.findViewById<FpsGraphView>(R.id.fps_graph_view)
val frameTimeGraphView = dialogView.findViewById<FrameTimeGraphView>(R.id.frame_time_graph_view)
val maxFpsText = dialogView.findViewById<TextView>(R.id.tv_max_fps)
val minFpsText = dialogView.findViewById<TextView>(R.id.tv_min_fps)
val avgFpsText = dialogView.findViewById<TextView>(R.id.tv_avg_fps)
val varianceText = dialogView.findViewById<TextView>(R.id.tv_variance)
val stdDevText = dialogView.findViewById<TextView>(R.id.tv_std_dev)
val smoothnessText = dialogView.findViewById<TextView>(R.id.tv_smoothness)
val fps1PercentText = dialogView.findViewById<TextView>(R.id.tv_1percent_low)
val fps01PercentText = dialogView.findViewById<TextView>(R.id.tv_01percent_low)
// Set session info
sessionInfoText.text = buildString {
appendLine("📅 ${analytics.sessionDate}")
appendLine("⏱️ ${analytics.sessionDuration}")
appendLine("📊 ${analytics.totalSamples} samples")
append("📁 ${logFile.name}")
}
// Set FPS statistics
maxFpsText.text = String.format("Max FPS: %.1f", analytics.fpsStats.maxFps)
minFpsText.text = String.format("Min FPS: %.1f", analytics.fpsStats.minFps)
avgFpsText.text = String.format("Avg FPS: %.1f", analytics.fpsStats.avgFps)
varianceText.text = String.format("Variance: %.2f", analytics.fpsStats.variance)
stdDevText.text = String.format("Std Dev: %.2f", analytics.fpsStats.standardDeviation)
smoothnessText.text = String.format("Smoothness: %.1f%%", analytics.fpsStats.smoothnessPercentage)
fps1PercentText.text = String.format("1%% Low: %.1f FPS", analytics.fpsStats.fps1PercentLow)
fps01PercentText.text = String.format("0.1%% Low: %.1f FPS", analytics.fpsStats.fps0_1PercentLow)
// Set FPS graph data
fpsGraphView.setData(
analytics.fpsTimeData,
analytics.fpsStats.avgFps,
analytics.fpsStats.fps1PercentLow
)
// Set Frame Time graph data
val avgFrameTime = if (analytics.fpsStats.avgFps > 0) {
1000.0 / analytics.fpsStats.avgFps
} else {
0.0
}
frameTimeGraphView.setData(analytics.frameTimeData, avgFrameTime)
// Get CPU graph views
val cpuUsageGraphView = dialogView.findViewById<CpuGraphView>(R.id.cpu_usage_graph_view)
val cpuTempGraphView = dialogView.findViewById<CpuTempGraphView>(R.id.cpu_temp_graph_view)
val cpuClockGraphView = dialogView.findViewById<CpuClockGraphView>(R.id.cpu_clock_graph_view)
// Get CPU statistics views
val maxCpuUsageText = dialogView.findViewById<TextView>(R.id.tv_max_cpu_usage)
val minCpuUsageText = dialogView.findViewById<TextView>(R.id.tv_min_cpu_usage)
val avgCpuUsageText = dialogView.findViewById<TextView>(R.id.tv_avg_cpu_usage)
val maxCpuTempText = dialogView.findViewById<TextView>(R.id.tv_max_cpu_temp)
val minCpuTempText = dialogView.findViewById<TextView>(R.id.tv_min_cpu_temp)
val avgCpuTempText = dialogView.findViewById<TextView>(R.id.tv_avg_cpu_temp)
// Set CPU graph data
cpuUsageGraphView.setData(analytics.cpuUsageTimeData, analytics.cpuStats.avgUsage)
cpuTempGraphView.setData(analytics.cpuTempTimeData, analytics.cpuStats.avgTemp)
cpuClockGraphView.setData(analytics.cpuClockTimeData)
// Set CPU statistics
maxCpuUsageText.text = String.format("Max Usage: %.0f%%", analytics.cpuStats.maxUsage)
minCpuUsageText.text = String.format("Min Usage: %.0f%%", analytics.cpuStats.minUsage)
avgCpuUsageText.text = String.format("Avg Usage: %.1f%%", analytics.cpuStats.avgUsage)
maxCpuTempText.text = String.format("Max Temp: %.1f°C", analytics.cpuStats.maxTemp)
minCpuTempText.text = String.format("Min Temp: %.1f°C", analytics.cpuStats.minTemp)
avgCpuTempText.text = String.format("Avg Temp: %.1f°C", analytics.cpuStats.avgTemp)
// Get GPU graph views
val gpuUsageGraphView = dialogView.findViewById<GpuUsageGraphView>(R.id.gpu_usage_graph_view)
val gpuTempGraphView = dialogView.findViewById<GpuTempGraphView>(R.id.gpu_temp_graph_view)
val gpuClockGraphView = dialogView.findViewById<GpuClockGraphView>(R.id.gpu_clock_graph_view)
// Get GPU statistics views
val maxGpuUsageText = dialogView.findViewById<TextView>(R.id.tv_max_gpu_usage)
val minGpuUsageText = dialogView.findViewById<TextView>(R.id.tv_min_gpu_usage)
val avgGpuUsageText = dialogView.findViewById<TextView>(R.id.tv_avg_gpu_usage)
val maxGpuClockText = dialogView.findViewById<TextView>(R.id.tv_max_gpu_clock)
val minGpuClockText = dialogView.findViewById<TextView>(R.id.tv_min_gpu_clock)
val avgGpuClockText = dialogView.findViewById<TextView>(R.id.tv_avg_gpu_clock)
val maxGpuTempText = dialogView.findViewById<TextView>(R.id.tv_max_gpu_temp)
val minGpuTempText = dialogView.findViewById<TextView>(R.id.tv_min_gpu_temp)
val avgGpuTempText = dialogView.findViewById<TextView>(R.id.tv_avg_gpu_temp)
// Set GPU graph data
gpuUsageGraphView.setData(analytics.gpuUsageTimeData, analytics.gpuStats.avgUsage)
gpuTempGraphView.setData(analytics.gpuTempTimeData, analytics.gpuStats.avgTemp)
gpuClockGraphView.setData(analytics.gpuClockTimeData, analytics.gpuStats.avgClock)
// Set GPU statistics
maxGpuUsageText.text = String.format("Max Usage: %.0f%%", analytics.gpuStats.maxUsage)
minGpuUsageText.text = String.format("Min Usage: %.0f%%", analytics.gpuStats.minUsage)
avgGpuUsageText.text = String.format("Avg Usage: %.1f%%", analytics.gpuStats.avgUsage)
maxGpuClockText.text = String.format("Max Clock: %.0f MHz", analytics.gpuStats.maxClock)
minGpuClockText.text = String.format("Min Clock: %.0f MHz", analytics.gpuStats.minClock)
avgGpuClockText.text = String.format("Avg Clock: %.0f MHz", analytics.gpuStats.avgClock)
maxGpuTempText.text = String.format("Max Temp: %.1f°C", analytics.gpuStats.maxTemp)
minGpuTempText.text = String.format("Min Temp: %.1f°C", analytics.gpuStats.minTemp)
avgGpuTempText.text = String.format("Avg Temp: %.1f°C", analytics.gpuStats.avgTemp)
// Create and show dialog with menu
val dialog = AlertDialog.Builder(requireContext())
.setTitle("📊 Session Analytics")
.setView(dialogView)
.setPositiveButton("⋮ Actions") { _, _ ->
// Will be overridden
}
.setNegativeButton("Close", null)
.create()
dialog.show()
// Override the Actions button to show menu
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
showActionsMenu(it, dialogView, analytics, logFile)
}
// Make sure dialog is wide enough
dialog.window?.setLayout(
(resources.displayMetrics.widthPixels * 0.95).toInt(),
android.view.ViewGroup.LayoutParams.WRAP_CONTENT
)
}
private fun showActionsMenu(
anchorView: View,
dialogView: View,
analytics: LogAnalytics,
logFile: GameBarLogFragment.LogFile
) {
val popup = android.widget.PopupMenu(requireContext(), anchorView)
popup.menu.apply {
add(0, 1, 1, "📊 Export Data (CSV)")
add(0, 2, 2, "📸 Save Graphics (PNG)")
add(0, 3, 3, "📤 Share Data (CSV)")
add(0, 4, 4, "🖼️ Share Graphics (PNG)")
}
popup.setOnMenuItemClickListener { item ->
when (item.itemId) {
1 -> {
exportLogFile(logFile)
true
}
2 -> {
saveGraphicsAsImage(dialogView, analytics, logFile)
true
}
3 -> {
shareLogFile(logFile)
true
}
4 -> {
shareGraphicsAsImage(dialogView, analytics, logFile)
true
}
else -> false
}
}
popup.show()
}
private fun saveGraphicsAsImage(view: View, analytics: LogAnalytics, logFile: GameBarLogFragment.LogFile) {
try {
// Ensure view is properly measured and laid out
view.post {
try {
// Create bitmap from view
val bitmap = captureViewAsBitmap(view)
// Save to external storage
val timeStamp = java.text.SimpleDateFormat("yyyyMMdd_HHmmss", java.util.Locale.getDefault()).format(java.util.Date())
val fileName = "GameBar_Stats_${appName}_$timeStamp.png"
val file = File(android.os.Environment.getExternalStorageDirectory(), fileName)
val fos = java.io.FileOutputStream(file)
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, fos)
fos.flush()
fos.close()
Toast.makeText(requireContext(), "Saved: ${file.absolutePath}", Toast.LENGTH_LONG).show()
} catch (e: Exception) {
Toast.makeText(requireContext(), "Failed to save: ${e.message}", Toast.LENGTH_LONG).show()
e.printStackTrace()
}
}
} catch (e: Exception) {
Toast.makeText(requireContext(), "Error: ${e.message}", Toast.LENGTH_LONG).show()
e.printStackTrace()
}
}
private fun shareGraphicsAsImage(view: View, analytics: LogAnalytics, logFile: GameBarLogFragment.LogFile) {
try {
// Ensure view is properly measured and laid out
view.post {
try {
// Create bitmap from view
val bitmap = captureViewAsBitmap(view)
// Save to cache directory
val timeStamp = java.text.SimpleDateFormat("yyyyMMdd_HHmmss", java.util.Locale.getDefault()).format(java.util.Date())
val fileName = "GameBar_Stats_${appName}_$timeStamp.png"
val file = File(requireContext().cacheDir, fileName)
val fos = java.io.FileOutputStream(file)
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, fos)
fos.flush()
fos.close()
view.isDrawingCacheEnabled = false
// Share the image
val uri = FileProvider.getUriForFile(
requireContext(),
"${requireContext().packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND)
intent.type = "image/png"
intent.putExtra(Intent.EXTRA_STREAM, uri)
intent.putExtra(Intent.EXTRA_SUBJECT, "GameBar Performance Stats - $appName")
intent.putExtra(Intent.EXTRA_TEXT, buildString {
appendLine("🎮 GameBar Performance Stats")
appendLine("")
appendLine("📱 App: $appName")
appendLine("📅 Session: ${analytics.sessionDate}")
appendLine("⏱️ Duration: ${analytics.sessionDuration}")
appendLine("")
appendLine("🎯 FPS: Avg ${String.format("%.1f", analytics.fpsStats.avgFps)} | Max ${String.format("%.1f", analytics.fpsStats.maxFps)}")
appendLine("✨ Smoothness: ${String.format("%.1f%%", analytics.fpsStats.smoothnessPercentage)}")
appendLine("🔥 1% Low: ${String.format("%.1f", analytics.fpsStats.fps1PercentLow)} FPS")
})
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val chooser = Intent.createChooser(intent, "Share Graphics")
startActivity(chooser)
Toast.makeText(requireContext(), "Graphics ready to share!", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(requireContext(), "Failed to share: ${e.message}", Toast.LENGTH_LONG).show()
e.printStackTrace()
}
}
} catch (e: Exception) {
Toast.makeText(requireContext(), "Error: ${e.message}", Toast.LENGTH_LONG).show()
e.printStackTrace()
}
}
private fun captureViewAsBitmap(view: View): android.graphics.Bitmap {
// The root view of the dialog is a ScrollView, get its content
val contentView = if (view is android.widget.ScrollView) {
// Get the LinearLayout inside the ScrollView
view.getChildAt(0)
} else {
view
}
// Measure the full content size (not just what's visible)
contentView.measure(
View.MeasureSpec.makeMeasureSpec(contentView.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
// Create bitmap with full content dimensions
val bitmap = android.graphics.Bitmap.createBitmap(
contentView.width,
contentView.measuredHeight,
android.graphics.Bitmap.Config.ARGB_8888
)
val canvas = android.graphics.Canvas(bitmap)
// Draw the entire content including off-screen parts
contentView.layout(
contentView.left,
contentView.top,
contentView.right,
contentView.bottom + contentView.measuredHeight
)
contentView.draw(canvas)
return bitmap
}
private fun shareLogFile(logFile: GameBarLogFragment.LogFile) {
try {
val intent = Intent(Intent.ACTION_SEND)
val file = File(logFile.path)
val uri = FileProvider.getUriForFile(
requireContext(),
"${requireContext().packageName}.fileprovider",
file
)
intent.type = "text/csv"
intent.putExtra(Intent.EXTRA_STREAM, uri)
intent.putExtra(Intent.EXTRA_SUBJECT, "GameBar Performance Log for $appName")
intent.putExtra(Intent.EXTRA_TEXT, "GameBar performance log file for $appName: ${logFile.name}")
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val chooser = Intent.createChooser(intent, "Share log file")
startActivity(chooser)
} catch (e: Exception) {
Toast.makeText(requireContext(), "File location: ${logFile.path}", Toast.LENGTH_LONG).show()
}
}
private fun exportLogFile(logFile: GameBarLogFragment.LogFile) {
Toast.makeText(requireContext(), "File saved at: ${logFile.path}", Toast.LENGTH_LONG).show()
}
private fun deleteLogFile(logFile: GameBarLogFragment.LogFile) {
AlertDialog.Builder(requireContext())
.setTitle("Delete Log File")
.setMessage("Are you sure you want to delete this log file?\\n\\n${logFile.name}")
.setPositiveButton("Delete") { _, _ ->
try {
val file = File(logFile.path)
if (file.delete()) {
Toast.makeText(requireContext(), "Log file deleted", Toast.LENGTH_SHORT).show()
loadPerAppLogHistory() // Refresh the list
} else {
Toast.makeText(requireContext(), "Failed to delete log file", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(requireContext(), "Error deleting file: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton("Cancel", null)
.show()
}
private fun formatFileSize(bytes: Long): String {
val kb = bytes / 1024.0
val mb = kb / 1024.0
return when {
mb >= 1 -> String.format("%.1f MB", mb)
kb >= 1 -> String.format("%.1f KB", kb)
else -> "$bytes bytes"
}
}
private fun formatDate(timestamp: Long): String {
val dateFormat = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault())
return dateFormat.format(Date(timestamp))
}
override fun onResume() {
super.onResume()
loadPerAppLogHistory()
}
}

View File

@@ -0,0 +1,211 @@
/*
* SPDX-FileCopyrightText: The LineageOS Project
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar.utils;
import android.util.Log;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
public final class FileUtils {
private static final String TAG = "FileUtils";
private FileUtils() {
// This class is not supposed to be instantiated
}
/**
* Reads the first line of text from the given file.
* Reference {@link BufferedReader#readLine()} for clarification on what a line is
*
* @return the read line contents, or null on failure
*/
public static String readOneLine(String fileName) {
String line = null;
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader(fileName), 512);
line = reader.readLine();
} catch (FileNotFoundException e) {
Log.w(TAG, "No such file " + fileName + " for reading", e);
} catch (IOException e) {
Log.e(TAG, "Could not read from file " + fileName, e);
} finally {
try {
if (reader != null) {
reader.close();
}
} catch (IOException e) {
// Ignored, not much we can do anyway
}
}
return line;
}
/**
* Writes the given value into the given file
*
* @return true on success, false on failure
*/
public static boolean writeLine(String fileName, String value) {
BufferedWriter writer = null;
try {
writer = new BufferedWriter(new FileWriter(fileName));
writer.write(value);
writer.flush();
} catch (FileNotFoundException e) {
Log.w(TAG, "No such file " + fileName + " for writing", e);
return false;
} catch (IOException e) {
Log.e(TAG, "Could not write to file " + fileName, e);
return false;
} finally {
try {
if (writer != null) {
writer.close();
}
} catch (IOException e) {
// Ignored, not much we can do anyway
}
}
return true;
}
/**
* Checks whether the given file exists
*
* @return true if exists, false if not
*/
public static boolean fileExists(String fileName) {
final File file = new File(fileName);
return file.exists();
}
/**
* Checks whether the given file is readable
*
* @return true if readable, false if not
*/
public static boolean isFileReadable(String fileName) {
final File file = new File(fileName);
return file.exists() && file.canRead();
}
/**
* Checks whether the given file is writable
*
* @return true if writable, false if not
*/
public static boolean isFileWritable(String fileName) {
final File file = new File(fileName);
return file.exists() && file.canWrite();
}
/**
* Deletes an existing file
*
* @return true if the delete was successful, false if not
*/
public static boolean delete(String fileName) {
final File file = new File(fileName);
boolean ok = false;
try {
ok = file.delete();
} catch (SecurityException e) {
Log.w(TAG, "SecurityException trying to delete " + fileName, e);
}
return ok;
}
/**
* Renames an existing file
*
* @return true if the rename was successful, false if not
*/
public static boolean rename(String srcPath, String dstPath) {
final File srcFile = new File(srcPath);
final File dstFile = new File(dstPath);
boolean ok = false;
try {
ok = srcFile.renameTo(dstFile);
} catch (SecurityException e) {
Log.w(TAG, "SecurityException trying to rename " + srcPath + " to " + dstPath, e);
} catch (NullPointerException e) {
Log.e(TAG, "NullPointerException trying to rename " + srcPath + " to " + dstPath, e);
}
return ok;
}
/**
* Writes the given value into the given file.
* @return true on success, false on failure
*/
public static boolean writeValue(String fileName, String value) {
BufferedWriter writer = null;
try {
writer = new BufferedWriter(new FileWriter(fileName));
writer.write(value);
writer.flush();
} catch (FileNotFoundException e) {
Log.w(TAG, "No such file " + fileName + " for writing", e);
return false;
} catch (IOException e) {
Log.e(TAG, "Could not write to file " + fileName, e);
return false;
} finally {
try {
if (writer != null) {
writer.close();
}
} catch (IOException e) {
// Ignored, not much we can do anyway
}
}
return true;
}
/**
* Reads the value from the given file.
* @return the value read from the file, or the default value if an error occurs
*/
public static String getFileValue(String fileName, String defaultValue) {
String value = defaultValue;
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader(fileName), 512);
value = reader.readLine();
} catch (FileNotFoundException e) {
Log.w(TAG, "No such file " + fileName + " for reading", e);
} catch (IOException e) {
Log.e(TAG, "Could not read from file " + fileName, e);
} finally {
try {
if (reader != null) {
reader.close();
}
} catch (IOException e) {
// Ignored, not much we can do anyway
}
}
return value;
}
}

View File

@@ -0,0 +1,502 @@
/*
* Copyright (C) 2016-2017 The Dirty Unicorns Project
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar.utils;
import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.PorterDuff;
import androidx.preference.*;
import androidx.core.content.res.TypedArrayUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.view.ContextThemeWrapper;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import com.android.gamebar.R;
import com.android.settingslib.widget.SettingsThemeHelper;
import com.google.android.material.slider.LabelFormatter;
import com.google.android.material.slider.Slider;
public class PartsCustomSeekBarPreference extends Preference implements Slider.OnChangeListener,
Slider.OnSliderTouchListener, View.OnClickListener, View.OnLongClickListener {
protected final String TAG = getClass().getName();
private static final String SETTINGS_NS = "http://schemas.android.com/apk/res/com.android.settings";
private static final String SETTINGS_NS_ALT = "http://schemas.android.com/apk/res-auto";
protected static final String ANDROIDNS = "http://schemas.android.com/apk/res/android";
protected int mInterval = 1;
protected boolean mShowSign = false;
protected String mUnits = "";
protected boolean mContinuousUpdates = false;
protected int mMinValue = 0;
protected int mMaxValue = 100;
protected boolean mDefaultValueExists = false;
protected int mDefaultValue;
protected int mValue;
protected TextView mValueTextView;
protected ImageView mResetImageView;
protected ImageView mMinusImageView;
protected ImageView mPlusImageView;
protected Slider mSlider;
protected boolean mTrackingTouch = false;
protected int mTrackingValue;
// Custom value mapping for non-linear values (like time durations)
protected int[] mCustomValues = null;
protected String[] mCustomLabels = null;
public PartsCustomSeekBarPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.PartsCustomSeekBarPreference);
try {
mShowSign = a.getBoolean(R.styleable.PartsCustomSeekBarPreference_showSign, mShowSign);
String units = a.getString(R.styleable.PartsCustomSeekBarPreference_units);
if (units != null)
mUnits = " " + units;
mContinuousUpdates = a.getBoolean(
R.styleable.PartsCustomSeekBarPreference_continuousUpdates, false);
} finally {
a.recycle();
}
String newInterval = attrs.getAttributeValue(SETTINGS_NS, "interval");
if (newInterval != null) {
mInterval = Integer.parseInt(newInterval);
}
if (newInterval == null) {
newInterval = attrs.getAttributeValue(SETTINGS_NS_ALT, "interval");
if (newInterval != null) mInterval = Integer.parseInt(newInterval);
}
if (newInterval == null) {
newInterval = attrs.getAttributeValue(ANDROIDNS, "interval");
if (newInterval != null) mInterval = Integer.parseInt(newInterval);
}
mMinValue = attrs.getAttributeIntValue(SETTINGS_NS, "min", mMinValue);
if (mMinValue == 0) {
int min = attrs.getAttributeIntValue(SETTINGS_NS_ALT, "min", mMinValue);
if (min != 0) mMinValue = min;
}
if (mMinValue == 0) {
int min = attrs.getAttributeIntValue(ANDROIDNS, "min", mMinValue);
if (min != 0) mMinValue = min;
}
mMaxValue = attrs.getAttributeIntValue(ANDROIDNS, "max", mMaxValue);
if (mMaxValue == 100) {
int max = attrs.getAttributeIntValue(SETTINGS_NS, "max", mMaxValue);
if (max != 100) mMaxValue = max;
}
if (mMaxValue == 100) {
int max = attrs.getAttributeIntValue(SETTINGS_NS_ALT, "max", mMaxValue);
if (max != 100) mMaxValue = max;
}
if (mMaxValue < mMinValue)
mMaxValue = mMinValue;
String defaultValue = attrs.getAttributeValue(ANDROIDNS, "defaultValue");
mDefaultValueExists = defaultValue != null && !defaultValue.isEmpty();
if (!mDefaultValueExists) {
defaultValue = attrs.getAttributeValue(SETTINGS_NS, "defaultValue");
mDefaultValueExists = defaultValue != null && !defaultValue.isEmpty();
}
if (!mDefaultValueExists) {
defaultValue = attrs.getAttributeValue(SETTINGS_NS_ALT, "defaultValue");
mDefaultValueExists = defaultValue != null && !defaultValue.isEmpty();
}
if (mDefaultValueExists) {
mDefaultValue = getLimitedValue(Integer.parseInt(defaultValue));
mValue = mDefaultValue;
} else {
mValue = mMinValue;
}
Context materialContext = new ContextThemeWrapper(context,
com.google.android.material.R.style.Theme_MaterialComponents_DayNight);
mSlider = new Slider(materialContext, attrs);
setLayoutResource(R.layout.preference_custom_seekbar);
}
public PartsCustomSeekBarPreference(Context context, AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}
public PartsCustomSeekBarPreference(Context context, AttributeSet attrs) {
this(context, attrs, TypedArrayUtils.getAttr(context,
androidx.preference.R.attr.preferenceStyle,
android.R.attr.preferenceStyle));
}
public PartsCustomSeekBarPreference(Context context) {
this(context, null);
}
@Override
public void onBindViewHolder(PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
try
{
// move our seekbar to the new view we've been given
ViewParent oldContainer = mSlider.getParent();
ViewGroup newContainer = (ViewGroup) holder.findViewById(R.id.seekbar);
if (oldContainer != newContainer) {
// remove the seekbar from the old view
if (oldContainer != null) {
((ViewGroup) oldContainer).removeView(mSlider);
}
// remove the existing seekbar (there may not be one) and add ours
newContainer.removeAllViews();
newContainer.addView(mSlider, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
}
} catch (Exception ex) {
Log.e(TAG, "Error binding view", ex);
}
mSlider.setValueTo(mMaxValue);
mSlider.setValueFrom(mMinValue);
mSlider.setValue(mValue);
mSlider.setEnabled(isEnabled());
mSlider.setLabelBehavior(LabelFormatter.LABEL_GONE);
mSlider.setTickVisible(false);
if (mInterval > 0) {
mSlider.setStepSize(mInterval);
} else {
Log.w(TAG, "Step size is zero or invalid: " + mInterval);
}
// Set up slider color
mSlider.setTrackActiveTintList(getContext().getColorStateList(
com.android.settingslib.widget.preference.slider.R.color
.settingslib_expressive_color_slider_track_active));
mSlider.setTrackInactiveTintList(getContext().getColorStateList(
com.android.settingslib.widget.preference.slider.R.color
.settingslib_expressive_color_slider_track_inactive));
mSlider.setThumbTintList(getContext().getColorStateList(
com.android.settingslib.widget.preference.slider.R.color
.settingslib_expressive_color_slider_thumb));
mSlider.setHaloTintList(getContext().getColorStateList(
com.android.settingslib.widget.preference.slider.R.color
.settingslib_expressive_color_slider_halo));
mSlider.setTickActiveTintList(getContext().getColorStateList(
com.android.settingslib.widget.preference.slider.R.color
.settingslib_expressive_color_slider_track_active));
mSlider.setTickInactiveTintList(getContext().getColorStateList(
com.android.settingslib.widget.preference.slider.R.color
.settingslib_expressive_color_slider_track_inactive));
// Set up slider size
if (SettingsThemeHelper.isExpressiveTheme(getContext())) {
Resources res = getContext().getResources();
mSlider.setTrackHeight(res.getDimensionPixelSize(
com.android.settingslib.widget.preference.slider.R.dimen
.settingslib_expressive_slider_track_height));
mSlider.setTrackInsideCornerSize(res.getDimensionPixelSize(
com.android.settingslib.widget.preference.slider.R.dimen
.settingslib_expressive_slider_track_inside_corner_size));
mSlider.setTrackStopIndicatorSize(res.getDimensionPixelSize(
com.android.settingslib.widget.preference.slider.R.dimen
.settingslib_expressive_slider_track_stop_indicator_size));
mSlider.setThumbWidth(res.getDimensionPixelSize(
com.android.settingslib.widget.preference.slider.R.dimen
.settingslib_expressive_slider_thumb_width));
mSlider.setThumbHeight(res.getDimensionPixelSize(
com.android.settingslib.widget.preference.slider.R.dimen
.settingslib_expressive_slider_thumb_height));
mSlider.setThumbElevation(res.getDimensionPixelSize(
com.android.settingslib.widget.preference.slider.R.dimen
.settingslib_expressive_slider_thumb_elevation));
mSlider.setThumbStrokeWidth(res.getDimensionPixelSize(
com.android.settingslib.widget.preference.slider.R.dimen
.settingslib_expressive_slider_thumb_stroke_width));
mSlider.setThumbTrackGapSize(res.getDimensionPixelSize(
com.android.settingslib.widget.preference.slider.R.dimen
.settingslib_expressive_slider_thumb_track_gap_size));
mSlider.setTickActiveRadius(res.getDimensionPixelSize(
com.android.settingslib.widget.preference.slider.R.dimen
.settingslib_expressive_slider_tick_radius));
mSlider.setTickInactiveRadius(res.getDimensionPixelSize(
com.android.settingslib.widget.preference.slider.R
.dimen.settingslib_expressive_slider_tick_radius));
}
mValueTextView = (TextView) holder.findViewById(R.id.value);
mResetImageView = (ImageView) holder.findViewById(R.id.reset);
mMinusImageView = (ImageView) holder.findViewById(R.id.minus);
mPlusImageView = (ImageView) holder.findViewById(R.id.plus);
updateValueViews();
mSlider.addOnChangeListener(this);
mSlider.addOnSliderTouchListener(this);
mResetImageView.setOnClickListener(this);
mMinusImageView.setOnClickListener(this);
mPlusImageView.setOnClickListener(this);
mResetImageView.setOnLongClickListener(this);
mMinusImageView.setOnLongClickListener(this);
mPlusImageView.setOnLongClickListener(this);
}
protected int getLimitedValue(int v) {
return v < mMinValue ? mMinValue : (v > mMaxValue ? mMaxValue : v);
}
protected String getTextValue(int v) {
if (mCustomLabels != null && v >= 0 && v < mCustomLabels.length) {
return mCustomLabels[v];
}
return (mShowSign && v > 0 ? "+" : "") + String.valueOf(v) + mUnits;
}
protected void updateValueViews() {
if (mValueTextView != null) {
String add = "";
if (mDefaultValueExists && mValue == mDefaultValue) {
add = " (" + getContext().getString(
R.string.custom_seekbar_default_value) + ")";
}
String textValue = getTextValue(mValue) + add;
if (mTrackingTouch && !mContinuousUpdates) {
textValue = getTextValue(mTrackingValue);
}
mValueTextView.setText(getContext().getString(
R.string.custom_seekbar_value, textValue));
}
if (mResetImageView != null) {
if (!mDefaultValueExists || mValue == mDefaultValue || mTrackingTouch)
mResetImageView.setVisibility(View.INVISIBLE);
else
mResetImageView.setVisibility(View.VISIBLE);
}
if (mMinusImageView != null) {
if (mValue == mMinValue || mTrackingTouch) {
mMinusImageView.setClickable(false);
mMinusImageView.setColorFilter(getContext().getColor(R.color.disabled_text_color),
PorterDuff.Mode.MULTIPLY);
} else {
mMinusImageView.setClickable(true);
mMinusImageView.clearColorFilter();
}
}
if (mPlusImageView != null) {
if (mValue == mMaxValue || mTrackingTouch) {
mPlusImageView.setClickable(false);
mPlusImageView.setColorFilter(getContext().getColor(R.color.disabled_text_color),
PorterDuff.Mode.MULTIPLY);
} else {
mPlusImageView.setClickable(true);
mPlusImageView.clearColorFilter();
}
}
}
protected void changeValue(int newValue) {
// for subclasses
}
@Override
public void onValueChange(Slider slider, float value, boolean fromUser) {
int newValue = getLimitedValue(Math.round(value));
if (mTrackingTouch && !mContinuousUpdates) {
mTrackingValue = newValue;
} else if (mValue != newValue) {
// change rejected, revert to the previous value
if (!callChangeListener(newValue)) {
mSlider.setValue(mValue);
return;
}
// change accepted, store it
changeValue(newValue);
persistInt(newValue);
mValue = newValue;
}
updateValueViews();
}
@Override
public void onStartTrackingTouch(Slider slider) {
mTrackingValue = mValue;
mTrackingTouch = true;
}
@Override
public void onStopTrackingTouch(Slider slider) {
mTrackingTouch = false;
if (!mContinuousUpdates)
onValueChange(mSlider, mTrackingValue, false);
notifyChanged();
}
@Override
public void onClick(View v) {
int id = v.getId();
if (id == R.id.reset) {
Toast.makeText(getContext(), getContext().getString(
R.string.custom_seekbar_default_value_to_set, getTextValue(mDefaultValue)),
Toast.LENGTH_LONG).show();
} else if (id == R.id.minus) {
setValue(mValue - mInterval, true);
} else if (id == R.id.plus) {
setValue(mValue + mInterval, true);
}
}
@Override
public boolean onLongClick(View v) {
int id = v.getId();
if (id == R.id.reset) {
setValue(mDefaultValue, true);
} else if (id == R.id.minus) {
int value = mMinValue;
if (mMaxValue - mMinValue > mInterval * 2 && mMaxValue + mMinValue < mValue * 2) {
value = Math.floorDiv(mMaxValue + mMinValue, 2);
}
setValue(value, true);
} else if (id == R.id.plus) {
int value = mMaxValue;
if (mMaxValue - mMinValue > mInterval * 2 && mMaxValue + mMinValue > mValue * 2) {
value = -1 * Math.floorDiv(-1 * (mMaxValue + mMinValue), 2);
}
setValue(value, true);
}
return true;
}
@Override
protected void onSetInitialValue(boolean restoreValue, Object defaultValue) {
if (restoreValue)
mValue = getPersistedInt(mValue);
}
@Override
public void setDefaultValue(Object defaultValue) {
if (defaultValue instanceof Integer)
setDefaultValue((Integer) defaultValue, mSlider != null);
else
setDefaultValue(defaultValue == null ? (String) null : defaultValue.toString(), mSlider != null);
}
public void setDefaultValue(int newValue, boolean update) {
newValue = getLimitedValue(newValue);
if (!mDefaultValueExists || mDefaultValue != newValue) {
mDefaultValueExists = true;
mDefaultValue = newValue;
if (update)
updateValueViews();
}
}
public void setDefaultValue(String newValue, boolean update) {
if (mDefaultValueExists && (newValue == null || newValue.isEmpty())) {
mDefaultValueExists = false;
if (update)
updateValueViews();
} else if (newValue != null && !newValue.isEmpty()) {
setDefaultValue(Integer.parseInt(newValue), update);
}
}
public void setMax(int max) {
mMaxValue = max;
if (mSlider != null) mSlider.setValueTo(mMaxValue);
}
public int getMax() {
return mMaxValue;
}
public void setMin(int min) {
mMinValue = min;
if (mSlider != null) mSlider.setValueFrom(mMinValue);
}
public void setValue(int newValue) {
mValue = getLimitedValue(newValue);
if (mSlider != null) mSlider.setValue(mValue);
onValueChange(mSlider, mValue, false);
notifyChanged();
}
public void setValue(int newValue, boolean update) {
newValue = getLimitedValue(newValue);
if (mValue != newValue) {
if (!callChangeListener(newValue)) {
return;
}
mValue = newValue;
persistInt(newValue);
changeValue(newValue); // if needed
if (update && mSlider != null)
mSlider.setValue(newValue);
updateValueViews();
notifyChanged();
}
}
public int getValue() {
return mValue;
}
public void setUnits(String units) {
mUnits = units;
updateValueViews();
}
public String getUnits() {
return mUnits;
}
// Custom value mapping methods for time durations and other non-linear scales
public void setCustomValues(int[] values, String[] labels) {
mCustomValues = values;
mCustomLabels = labels;
if (values != null && values.length > 0) {
setMax(values.length - 1);
setMin(0);
}
updateValueViews();
}
public int getMappedValue() {
if (mCustomValues != null && mValue >= 0 && mValue < mCustomValues.length) {
return mCustomValues[mValue];
}
return mValue;
}
public void refresh(int newValue) {
// Update the value without triggering change listeners to avoid infinite recursion
mValue = getLimitedValue(newValue);
if (mSlider != null) {
// Temporarily remove listener to prevent triggering onValueChange
mSlider.removeOnChangeListener(this);
mSlider.setValue(mValue);
mSlider.addOnChangeListener(this);
}
updateValueViews();
persistInt(mValue);
}
}

View File

@@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: 2025 kenway214
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar.utils;
import android.app.Activity;
import android.content.ComponentName;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.provider.Settings;
import android.service.quicksettings.TileService;
import android.util.Log;
import java.util.HashMap;
import java.util.Map;
import com.android.gamebar.GameBarSettingsActivity;
import com.android.gamebar.GameBarTileService;
public final class TileHandlerActivity extends Activity {
private static final String TAG = "TileHandlerActivity";
// Map QS Tile services to their corresponding activity
private static final Map<String, Class<?>> TILE_ACTIVITY_MAP = new HashMap<>();
static {
TILE_ACTIVITY_MAP.put(GameBarTileService.class.getName(), GameBarSettingsActivity.class);
}
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final Intent intent = getIntent();
if (intent == null || !TileService.ACTION_QS_TILE_PREFERENCES.equals(intent.getAction())) {
Log.e(TAG, "Invalid or null intent received");
finish();
return;
}
final ComponentName qsTile = intent.getParcelableExtra(Intent.EXTRA_COMPONENT_NAME);
if (qsTile == null) {
Log.e(TAG, "No QS tile component found in intent");
finish();
return;
}
final String qsName = qsTile.getClassName();
final Intent targetIntent = new Intent();
// Check if the tile is mapped to an activity
if (TILE_ACTIVITY_MAP.containsKey(qsName)) {
targetIntent.setClass(this, TILE_ACTIVITY_MAP.get(qsName));
Log.d(TAG, "Launching settings activity for QS tile: " + qsName);
} else {
// Default: Open app settings for the QS tile's package
final String packageName = qsTile.getPackageName();
if (packageName == null) {
Log.e(TAG, "QS tile package name is null");
finish();
return;
}
targetIntent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
targetIntent.setData(Uri.fromParts("package", packageName, null));
Log.d(TAG, "Opening app info for package: " + packageName);
}
// Ensure proper navigation behavior
targetIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
Intent.FLAG_ACTIVITY_CLEAR_TASK |
Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(targetIntent);
finish();
}
}

View File

@@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: The LineageOS Project
* SPDX-License-Identifier: Apache-2.0
*/
package com.android.gamebar.utils;
import android.app.StatusBarManager;
import android.content.ComponentName;
import android.content.Context;
import android.graphics.drawable.Icon;
import android.widget.Toast;
import com.android.gamebar.R;
public class TileUtils {
public static void requestAddTileService(Context context, Class<?> tileServiceClass, int labelResId, int iconResId) {
ComponentName componentName = new ComponentName(context, tileServiceClass);
String label = context.getString(labelResId);
Icon icon = Icon.createWithResource(context, iconResId);
StatusBarManager sbm = (StatusBarManager) context.getSystemService(Context.STATUS_BAR_SERVICE);
if (sbm != null) {
sbm.requestAddTileService(
componentName,
label,
icon,
context.getMainExecutor(),
result -> handleResult(context, result)
);
}
}
private static void handleResult(Context context, Integer result) {
if (result == null)
return;
switch (result) {
case StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ADDED:
Toast.makeText(context, R.string.tile_added, Toast.LENGTH_SHORT).show();
break;
case StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_NOT_ADDED:
Toast.makeText(context, R.string.tile_not_added, Toast.LENGTH_SHORT).show();
break;
case StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ALREADY_ADDED:
Toast.makeText(context, R.string.tile_already_added, Toast.LENGTH_SHORT).show();
break;
}
}
}