cleaned structure

This commit is contained in:
tom.hempel
2025-09-18 11:55:54 +02:00
parent be47be3b82
commit 5283703f3d
65 changed files with 1511 additions and 0 deletions

3
Code/.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

1
Code/.idea/.name generated Normal file
View File

@ -0,0 +1 @@
Lab Recorder

6
Code/.idea/AndroidProjectSystem.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

848
Code/.idea/caches/deviceStreaming.xml generated Normal file
View File

@ -0,0 +1,848 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceStreaming">
<option name="deviceSelectionList">
<list>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="Sony" />
<option name="codename" value="A402SO" />
<option name="id" value="A402SO" />
<option name="labId" value="google" />
<option name="manufacturer" value="Sony" />
<option name="name" value="Xperia 10" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2520" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="27" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="F01L" />
<option name="id" value="F01L" />
<option name="labId" value="google" />
<option name="manufacturer" value="FUJITSU" />
<option name="name" value="F-01L" />
<option name="screenDensity" value="360" />
<option name="screenX" value="720" />
<option name="screenY" value="1280" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="OnePlus" />
<option name="codename" value="OP535DL1" />
<option name="id" value="OP535DL1" />
<option name="labId" value="google" />
<option name="manufacturer" value="OnePlus" />
<option name="name" value="CPH2409" />
<option name="screenDensity" value="401" />
<option name="screenX" value="1080" />
<option name="screenY" value="2412" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="OnePlus" />
<option name="codename" value="OP5552L1" />
<option name="id" value="OP5552L1" />
<option name="labId" value="google" />
<option name="manufacturer" value="OnePlus" />
<option name="name" value="CPH2415" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2412" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="OPPO" />
<option name="codename" value="OP573DL1" />
<option name="id" value="OP573DL1" />
<option name="labId" value="google" />
<option name="manufacturer" value="OPPO" />
<option name="name" value="CPH2557" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="28" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="SH-01L" />
<option name="id" value="SH-01L" />
<option name="labId" value="google" />
<option name="manufacturer" value="SHARP" />
<option name="name" value="AQUOS sense2 SH-01L" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="a14m" />
<option name="id" value="a14m" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="SM-A145R" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2408" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="a15" />
<option name="id" value="a15" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="A15" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="a15x" />
<option name="id" value="a15x" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="A15 5G" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="a16x" />
<option name="id" value="a16x" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="A16 5G" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="a35x" />
<option name="id" value="a35x" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="A35" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="akita" />
<option name="id" value="akita" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="google" />
<option name="codename" value="akita" />
<option name="id" value="akita" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="motorola" />
<option name="codename" value="arcfox" />
<option name="id" value="arcfox" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="razr plus 2024" />
<option name="screenDensity" value="360" />
<option name="screenX" value="1080" />
<option name="screenY" value="1272" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="motorola" />
<option name="codename" value="austin" />
<option name="id" value="austin" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="moto g 5G (2022)" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="b0q" />
<option name="id" value="b0q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S22 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="b6q" />
<option name="id" value="b6q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Flip 6" />
<option name="screenDensity" value="340" />
<option name="screenX" value="1080" />
<option name="screenY" value="2640" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="32" />
<option name="brand" value="google" />
<option name="codename" value="bluejay" />
<option name="id" value="bluejay" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="caiman" />
<option name="id" value="caiman" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro" />
<option name="screenDensity" value="360" />
<option name="screenX" value="960" />
<option name="screenY" value="2142" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="google" />
<option name="codename" value="caiman" />
<option name="id" value="caiman" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro" />
<option name="screenDensity" value="360" />
<option name="screenX" value="960" />
<option name="screenY" value="2142" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="comet" />
<option name="default" value="true" />
<option name="id" value="comet" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro Fold" />
<option name="screenDensity" value="390" />
<option name="screenX" value="2076" />
<option name="screenY" value="2152" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="google" />
<option name="codename" value="comet" />
<option name="default" value="true" />
<option name="id" value="comet" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro Fold" />
<option name="screenDensity" value="390" />
<option name="screenX" value="2076" />
<option name="screenY" value="2152" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="29" />
<option name="brand" value="samsung" />
<option name="codename" value="crownqlteue" />
<option name="id" value="crownqlteue" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Note9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2220" />
<option name="screenY" value="1080" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="dm2q" />
<option name="id" value="dm2q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="S23 Plus" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="dm3q" />
<option name="id" value="dm3q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S23 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="motorola" />
<option name="codename" value="dubai" />
<option name="id" value="dubai" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="edge 30" />
<option name="screenDensity" value="405" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="e1q" />
<option name="default" value="true" />
<option name="id" value="e1q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S24" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="e3q" />
<option name="id" value="e3q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S24 Ultra" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1440" />
<option name="screenY" value="3120" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="eos" />
<option name="id" value="eos" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Eos" />
<option name="screenDensity" value="320" />
<option name="screenX" value="384" />
<option name="screenY" value="384" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="motorola" />
<option name="codename" value="eqe" />
<option name="id" value="eqe" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="edge 50 pro" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1220" />
<option name="screenY" value="2712" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix_camera" />
<option name="id" value="felix_camera" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold (Camera-enabled)" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="motorola" />
<option name="codename" value="fogona" />
<option name="id" value="fogona" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="moto g play - 2024" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="motorola" />
<option name="codename" value="fogos" />
<option name="id" value="fogos" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="moto g34 5G" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="g0q" />
<option name="id" value="g0q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="SM-S906U1" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="gta9pwifi" />
<option name="id" value="gta9pwifi" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="SM-X210" />
<option name="screenDensity" value="240" />
<option name="screenX" value="1200" />
<option name="screenY" value="1920" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="gts7lwifi" />
<option name="id" value="gts7lwifi" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="SM-T870" />
<option name="screenDensity" value="340" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="gts7xllite" />
<option name="id" value="gts7xllite" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="SM-T738U" />
<option name="screenDensity" value="340" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="gts8uwifi" />
<option name="formFactor" value="Tablet" />
<option name="id" value="gts8uwifi" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Tab S8 Ultra" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1848" />
<option name="screenY" value="2960" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="gts8wifi" />
<option name="formFactor" value="Tablet" />
<option name="id" value="gts8wifi" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Tab S8" />
<option name="screenDensity" value="274" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="gts9fe" />
<option name="id" value="gts9fe" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Tab S9 FE 5G" />
<option name="screenDensity" value="280" />
<option name="screenX" value="1440" />
<option name="screenY" value="2304" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="gts9wifi" />
<option name="id" value="gts9wifi" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="SM-X710" />
<option name="screenDensity" value="340" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="husky" />
<option name="id" value="husky" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8 Pro" />
<option name="screenDensity" value="390" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="motorola" />
<option name="codename" value="java" />
<option name="id" value="java" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="G20" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="komodo" />
<option name="id" value="komodo" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro XL" />
<option name="screenDensity" value="360" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="google" />
<option name="codename" value="komodo" />
<option name="id" value="komodo" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro XL" />
<option name="screenDensity" value="360" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="motorola" />
<option name="codename" value="lion" />
<option name="id" value="lion" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="moto g04" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1612" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="lynx" />
<option name="id" value="lynx" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="motorola" />
<option name="codename" value="lyriq" />
<option name="id" value="lyriq" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="edge 40" />
<option name="screenDensity" value="400" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="motorola" />
<option name="codename" value="manaus" />
<option name="id" value="manaus" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="edge 40 neo" />
<option name="screenDensity" value="400" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="motorola" />
<option name="codename" value="maui" />
<option name="id" value="maui" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="moto g play - 2023" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="o1q" />
<option name="id" value="o1q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S21" />
<option name="screenDensity" value="421" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="google" />
<option name="codename" value="oriole" />
<option name="id" value="oriole" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="samsung" />
<option name="codename" value="pa3q" />
<option name="id" value="pa3q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S25 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3120" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="panther" />
<option name="id" value="panther" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q5q" />
<option name="id" value="q5q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold5" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1812" />
<option name="screenY" value="2176" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q6q" />
<option name="id" value="q6q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1856" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="r11" />
<option name="formFactor" value="Wear OS" />
<option name="id" value="r11" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Watch" />
<option name="screenDensity" value="320" />
<option name="screenX" value="384" />
<option name="screenY" value="384" />
<option name="type" value="WEAR_OS" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="r11q" />
<option name="id" value="r11q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="SM-S711U" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="redfin" />
<option name="id" value="redfin" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 5" />
<option name="screenDensity" value="440" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="shiba" />
<option name="id" value="shiba" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="t2q" />
<option name="id" value="t2q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S21 Plus" />
<option name="screenDensity" value="394" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="tangorpro" />
<option name="formFactor" value="Tablet" />
<option name="id" value="tangorpro" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Tablet" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="google" />
<option name="codename" value="tegu" />
<option name="id" value="tegu" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="tokay" />
<option name="default" value="true" />
<option name="id" value="tokay" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="google" />
<option name="codename" value="tokay" />
<option name="default" value="true" />
<option name="id" value="tokay" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="tokay" />
<option name="default" value="true" />
<option name="id" value="tokay" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="xcover7" />
<option name="id" value="xcover7" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="SM-G556B" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2408" />
</PersistentDeviceSelectionData>
</list>
</option>
</component>
</project>

123
Code/.idea/codeStyles/Project.xml generated Normal file
View File

@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
Code/.idea/compiler.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

18
Code/.idea/deploymentTargetSelector.xml generated Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-07-19T16:32:36.134427800Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=b6bfcb67" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>

19
Code/.idea/gradle.xml generated Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

View File

@ -0,0 +1,61 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

10
Code/.idea/migrations.xml generated Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

9
Code/.idea/misc.xml generated Normal file
View File

@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

17
Code/.idea/runConfigurations.xml generated Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

6
Code/.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

1
Code/app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

73
Code/app/build.gradle.kts Normal file
View File

@ -0,0 +1,73 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.tomhempel.labrecorder"
compileSdk = 36
defaultConfig {
applicationId = "com.tomhempel.labrecorder"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
viewBinding = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.material)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.navigation.fragment.ktx)
implementation(libs.androidx.navigation.ui.ktx)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation("androidx.activity:activity-compose:1.9.0")
implementation("androidx.compose.material:material-icons-extended-android:1.6.8")
implementation(platform("androidx.compose:compose-bom:2024.06.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("com.patrykandpatrick.vico:compose-m3:1.14.0")
implementation("com.patrykandpatrick.vico:core:1.14.0")
implementation("com.github.PhilJay:MPAndroidChart:v3.1.0")
}

21
Code/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.tomhempel.labrecorder",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 24
}

View File

@ -0,0 +1,24 @@
package com.tomhempel.labrecorder
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.tomhempel.labrecorder", appContext.packageName)
}
}

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.bluetooth"
android:required="false" />
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="false" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/logo"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.LabRecorder">
<activity
android:name=".ParticipantSelectionActivity"
android:exported="true"
android:theme="@style/Theme.LabRecorder">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
android:exported="false"
android:theme="@style/Theme.LabRecorder" />
<activity
android:name=".DataInspectionActivity"
android:exported="false"
android:theme="@style/Theme.LabRecorder" />
<activity
android:name=".TimeNormalizedPlotActivity"
android:exported="false"
android:theme="@style/Theme.LabRecorder" />
</application>
</manifest>

View File

@ -0,0 +1,221 @@
package com.tomhempel.labrecorder
import android.content.Intent
import android.os.Bundle
import android.os.Environment
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tomhempel.labrecorder.ui.theme.LabRecorderTheme
import java.io.File
class DataInspectionActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
LabRecorderTheme {
DataInspectionScreen(
onNavigateBack = { finish() },
onRecordingSelected = { recordingPath, isSingleParticipant ->
val intent = Intent(this, TimeNormalizedPlotActivity::class.java).apply {
putExtra("RECORDING_PATH", recordingPath)
}
startActivity(intent)
}
)
}
}
}
}
enum class RecordingType {
SINGLE, GROUP
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DataInspectionScreen(
onNavigateBack: () -> Unit,
onRecordingSelected: (String, Boolean) -> Unit
) {
val baseDir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "LabRecorder")
var recordings by remember { mutableStateOf(listRecordings(baseDir)) }
var selectedTab by remember { mutableStateOf(RecordingType.SINGLE) }
Scaffold(
topBar = {
Column {
TopAppBar(
title = { Text("Recorded Data") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
TabRow(
selectedTabIndex = if (selectedTab == RecordingType.SINGLE) 0 else 1
) {
Tab(
selected = selectedTab == RecordingType.SINGLE,
onClick = { selectedTab = RecordingType.SINGLE },
text = { Text("Single Recordings") }
)
Tab(
selected = selectedTab == RecordingType.GROUP,
onClick = { selectedTab = RecordingType.GROUP },
text = { Text("Group Recordings") }
)
}
}
}
) { padding ->
val filteredRecordings = recordings.filter {
when (selectedTab) {
RecordingType.SINGLE -> it.isSingleParticipant
RecordingType.GROUP -> !it.isSingleParticipant
}
}
if (filteredRecordings.isEmpty()) {
// Show empty state
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = when (selectedTab) {
RecordingType.SINGLE -> "No single participant recordings found"
RecordingType.GROUP -> "No group recordings found"
},
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 16.dp,
bottom = 24.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filteredRecordings) { recording ->
RecordingItem(
recording = recording,
onClick = {
val recordingPath = if (recording.isSingleParticipant) {
File(baseDir, "SingleRecordings/${recording.name}").absolutePath
} else {
File(baseDir, recording.name).absolutePath
}
onRecordingSelected(recordingPath, recording.isSingleParticipant)
}
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecordingItem(
recording: RecordingInfo,
onClick: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
onClick = onClick
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = recording.name,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (recording.isSingleParticipant)
"Participant ID: ${recording.name}"
else
"Group ID: ${recording.name}",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Files: ${recording.fileCount}",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
data class RecordingInfo(
val name: String,
val isSingleParticipant: Boolean,
val fileCount: Int
)
private fun listRecordings(baseDir: File): List<RecordingInfo> {
if (!baseDir.exists()) return emptyList()
val recordings = mutableListOf<RecordingInfo>()
// Check SingleRecordings directory
val singleRecordingsDir = File(baseDir, "SingleRecordings")
if (singleRecordingsDir.exists()) {
singleRecordingsDir.listFiles()?.forEach { participantDir ->
if (participantDir.isDirectory) {
recordings.add(
RecordingInfo(
name = participantDir.name,
isSingleParticipant = true,
fileCount = participantDir.listFiles()?.size ?: 0
)
)
}
}
}
// Check group recordings (directories directly in baseDir except SingleRecordings)
baseDir.listFiles()?.forEach { dir ->
if (dir.isDirectory && dir.name != "SingleRecordings") {
recordings.add(
RecordingInfo(
name = dir.name,
isSingleParticipant = false,
fileCount = dir.listFiles()?.size ?: 0
)
)
}
}
return recordings.sortedBy { it.name }
}

View File

@ -0,0 +1,822 @@
package com.tomhempel.labrecorder
import android.Manifest
import android.annotation.SuppressLint
import android.app.Application
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.activity.enableEdgeToEdge
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.tomhempel.labrecorder.ui.theme.LabRecorderTheme
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
import android.content.Intent
import androidx.appcompat.app.AlertDialog
private const val TAG = "LabRecorder"
// Standard Bluetooth Service and Characteristic UUIDs
private val HR_SERVICE_UUID: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
private val HR_MEASUREMENT_CHAR_UUID: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
data class BleDevice(val name: String, val address: String)
@SuppressLint("MissingPermission")
class MainViewModel(application: Application) : AndroidViewModel(application) {
// --- Bluetooth & System Services ---
private val bluetoothManager: BluetoothManager by lazy {
application.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
}
private val bluetoothAdapter by lazy { bluetoothManager.adapter }
private val bluetoothLeScanner by lazy { bluetoothAdapter.bluetoothLeScanner }
// --- UI State Management ---
val isScanning = mutableStateOf(false)
val discoveredDevices = mutableStateListOf<BleDevice>()
val groupId = mutableStateOf("")
val participantId = mutableStateOf("") // New state for single participant ID
val isRecording = mutableStateOf(false)
val isIntervalRunning = mutableStateOf(false)
val connectionState1 = mutableStateOf("Disconnected")
val connectionState2 = mutableStateOf("Disconnected")
val hrValue1 = mutableStateOf(0)
val hrValue2 = mutableStateOf(0)
val logMessages = mutableStateListOf<String>()
val selectedDevice1 = mutableStateOf<BleDevice?>(null)
val selectedDevice2 = mutableStateOf<BleDevice?>(null)
val numberOfParticipants = mutableStateOf(0) // New state for number of participants
// --- Connection & File Management ---
private var gatt1: BluetoothGatt? = null
private var gatt2: BluetoothGatt? = null
private var hrWriter1: BufferedWriter? = null
private var rrWriter1: BufferedWriter? = null
private var hrWriter2: BufferedWriter? = null
private var rrWriter2: BufferedWriter? = null
private var timestampWriter: BufferedWriter? = null
private var scanTimeoutJob: Job? = null
init {
addLog("App initialized. Welcome!")
}
// --- Logging Helper ---
private fun addLog(message: String) {
val timeStamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
val logEntry = "$timeStamp: $message"
logMessages.add(0, logEntry)
if (logMessages.size > 100) {
logMessages.removeLast()
}
Log.d(TAG, message)
}
// --- GATT Callback for Connection & Data Events ---
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
val deviceAddress = gatt.device.address
val (stateToUpdate, hrToUpdate, participant) = if (deviceAddress == gatt1?.device?.address) {
Triple(connectionState1, hrValue1, "P1")
} else {
Triple(connectionState2, hrValue2, "P2")
}
if (status == BluetoothGatt.GATT_SUCCESS) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
stateToUpdate.value = "Connected"
addLog("[$participant] Connected to $deviceAddress. Discovering services...")
gatt.discoverServices()
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
stateToUpdate.value = "Disconnected"
hrToUpdate.value = 0
addLog("[$participant] Disconnected from $deviceAddress.")
gatt.close()
}
} else {
stateToUpdate.value = "Error"
hrToUpdate.value = 0
addLog("[$participant] ERROR: GATT Error for $deviceAddress. Status: $status")
gatt.close()
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
val participant = if (gatt.device.address == gatt1?.device?.address) "P1" else "P2"
if (status == BluetoothGatt.GATT_SUCCESS) {
addLog("[$participant] Services discovered. Enabling HR notifications.")
enableHeartRateNotifications(gatt)
} else {
addLog("[$participant] ERROR: Service discovery failed.")
}
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
if (characteristic.uuid == HR_MEASUREMENT_CHAR_UUID) {
parseAndRecordData(gatt.device.address, value)
}
}
}
// --- Scanning Logic ---
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val deviceName = result.device.name
if (deviceName?.startsWith("Polar", ignoreCase = true) == true) {
val device = BleDevice(deviceName, result.device.address)
if (discoveredDevices.none { it.address == device.address }) {
addLog("Found device: ${device.name}")
discoveredDevices.add(device)
if (discoveredDevices.size >= numberOfParticipants.value) {
addLog("Found ${numberOfParticipants.value} Polar device(s). Stopping scan.")
stopBleScan()
}
}
}
}
override fun onScanFailed(errorCode: Int) {
super.onScanFailed(errorCode)
addLog("ERROR: BLE Scan Failed with code: $errorCode")
isScanning.value = false
}
}
// --- Data Processing ---
private fun parseAndRecordData(deviceAddress: String, data: ByteArray) {
val isDevice1 = deviceAddress == gatt1?.device?.address
val hrState = if (isDevice1) hrValue1 else hrValue2
val currentHrWriter = if (isDevice1) hrWriter1 else hrWriter2
val currentRrWriter = if (isDevice1) rrWriter1 else rrWriter2
val flags = data[0].toInt()
val is16BitFormat = (flags and 0x01) != 0
val hrValue = if (is16BitFormat) {
(data[1].toInt() and 0xFF) or ((data[2].toInt() and 0xFF) shl 8)
} else {
data[1].toInt() and 0xFF
}
hrState.value = hrValue
currentHrWriter?.let { writer ->
try {
val timestamp = System.currentTimeMillis()
writer.write("$timestamp,$hrValue\n")
} catch (e: IOException) {
Log.e(TAG, "Error writing HR data for $deviceAddress", e)
}
}
val rrIntervalsPresent = (flags and 0x10) != 0
if (rrIntervalsPresent) {
var offset = if (is16BitFormat) 3 else 2
while (offset < data.size) {
val rrValue = ((data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8))
currentRrWriter?.let { writer ->
try {
val timestamp = System.currentTimeMillis()
writer.write("$timestamp,$rrValue\n")
} catch (e: IOException) {
Log.e(TAG, "Error writing RR data for $deviceAddress", e)
}
}
offset += 2
}
}
}
// --- BLE Actions ---
private fun enableHeartRateNotifications(gatt: BluetoothGatt) {
val hrCharacteristic = gatt.getService(HR_SERVICE_UUID)?.getCharacteristic(HR_MEASUREMENT_CHAR_UUID)
if (hrCharacteristic == null) {
addLog("ERROR: Heart Rate characteristic not found on ${gatt.device.address}")
return
}
gatt.setCharacteristicNotification(hrCharacteristic, true)
val descriptor = hrCharacteristic.getDescriptor(CCCD_UUID)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
} else {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
}
addLog("Enabling notifications for ${gatt.device.address}")
}
fun startBleScan() {
addLog("Starting automatic BLE scan (2s or 2 devices)...")
disconnectAll()
discoveredDevices.clear()
selectedDevice1.value = null
selectedDevice2.value = null
isScanning.value = true
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
bluetoothLeScanner?.startScan(null, settings, scanCallback)
scanTimeoutJob?.cancel()
scanTimeoutJob = viewModelScope.launch {
delay(2000)
if (isScanning.value) {
addLog("Scan timeout (2s) reached.")
stopBleScan()
}
}
}
fun stopBleScan() {
if (!isScanning.value) return
addLog("Stopping BLE scan.")
isScanning.value = false
scanTimeoutJob?.cancel()
scanTimeoutJob = null
bluetoothLeScanner?.stopScan(scanCallback)
// Auto-select devices after scan stops and connect
if (discoveredDevices.isNotEmpty()) {
selectedDevice1.value = discoveredDevices.getOrNull(0)
selectedDevice2.value = discoveredDevices.getOrNull(1)
connectToDevices()
}
}
fun connectToDevices() {
val address1 = selectedDevice1.value?.address ?: ""
val address2 = if (numberOfParticipants.value == 2) selectedDevice2.value?.address else ""
disconnectAll()
if (address1.isNotEmpty()) {
val device = bluetoothAdapter?.getRemoteDevice(address1)
addLog("Attempting to connect to P1: $address1")
gatt1 = device?.connectGatt(getApplication(), false, gattCallback)
}
if (address2?.isNotEmpty() == true && address1 != address2) {
val device = bluetoothAdapter?.getRemoteDevice(address2)
addLog("Attempting to connect to P2: $address2")
gatt2 = device?.connectGatt(getApplication(), false, gattCallback)
}
}
fun disconnectAll() {
gatt1?.disconnect()
gatt2?.disconnect()
}
// --- File Recording Logic ---
fun startRecording() {
if (numberOfParticipants.value == 2 && groupId.value.isBlank()) {
addLog("ERROR: Group ID cannot be empty.")
Toast.makeText(getApplication(), "Group ID cannot be empty.", Toast.LENGTH_SHORT).show()
return
} else if (numberOfParticipants.value == 1 && participantId.value.isBlank()) {
addLog("ERROR: Participant ID cannot be empty.")
Toast.makeText(getApplication(), "Participant ID cannot be empty.", Toast.LENGTH_SHORT).show()
return
}
val baseDir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "LabRecorder")
val recordingDir = if (numberOfParticipants.value == 2) {
addLog("Starting recording for group: ${groupId.value}")
File(baseDir, groupId.value)
} else {
addLog("Starting recording for participant: ${participantId.value}")
val singleRecordingsDir = File(baseDir, "SingleRecordings")
File(singleRecordingsDir, participantId.value)
}
if (!recordingDir.exists() && !recordingDir.mkdirs()) {
addLog("ERROR: Failed to create recording directory.")
return
}
if (gatt1?.device?.address != null) {
val deviceDir = if (numberOfParticipants.value == 2) {
File(recordingDir, "Participant_1")
} else {
recordingDir // For single participant, use the participant directory directly
}
deviceDir.mkdirs()
try {
hrWriter1 = BufferedWriter(FileWriter(File(deviceDir, "hr.csv"))).apply { write("timestamp,hr\n") }
rrWriter1 = BufferedWriter(FileWriter(File(deviceDir, "rr.csv"))).apply { write("timestamp,rr_ms\n") }
} catch (e: IOException) { addLog("ERROR: Failed creating writers for P1.") }
}
if (numberOfParticipants.value == 2 && gatt2?.device?.address != null) {
val deviceDir = File(recordingDir, "Participant_2")
deviceDir.mkdirs()
try {
hrWriter2 = BufferedWriter(FileWriter(File(deviceDir, "hr.csv"))).apply { write("timestamp,hr\n") }
rrWriter2 = BufferedWriter(FileWriter(File(deviceDir, "rr.csv"))).apply { write("timestamp,rr_ms\n") }
} catch (e: IOException) { addLog("ERROR: Failed creating writers for P2.") }
}
try {
timestampWriter = BufferedWriter(FileWriter(File(recordingDir, "timestamps.csv"))).apply { write("timestamp,event_type\n") }
} catch(e: IOException) { addLog("ERROR: Failed creating timestamp writer.") }
isRecording.value = true
Toast.makeText(getApplication(), "Recording started.", Toast.LENGTH_SHORT).show()
}
fun stopRecording() {
addLog("Stopping recording.")
isRecording.value = false
isIntervalRunning.value = false
try {
hrWriter1?.close()
rrWriter1?.close()
hrWriter2?.close()
rrWriter2?.close()
timestampWriter?.close()
} catch (e: IOException) { addLog("ERROR: Failed closing file writers.") }
hrWriter1 = null; rrWriter1 = null; hrWriter2 = null; rrWriter2 = null; timestampWriter = null
Toast.makeText(getApplication(), "Recording stopped.", Toast.LENGTH_SHORT).show()
}
fun markTimestamp(eventType: String) {
if (!isRecording.value) {
val msg = "Must be recording to mark a timestamp."
addLog("INFO: $msg")
Toast.makeText(getApplication(), msg, Toast.LENGTH_SHORT).show()
return
}
timestampWriter?.let { writer ->
try {
val timestamp = System.currentTimeMillis()
writer.write("$timestamp,$eventType\n")
writer.flush()
val msg = "'$eventType' marked."
addLog(msg)
Toast.makeText(getApplication(), msg, Toast.LENGTH_SHORT).show()
} catch (e: IOException) {
addLog("ERROR: Could not write timestamp.")
}
}
}
override fun onCleared() {
super.onCleared()
if (isRecording.value) {
stopRecording()
}
disconnectAll()
addLog("ViewModel cleared. Resources released.")
}
}
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
private var showExitDialog by mutableStateOf(false)
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (permissions.values.all { it }) {
// Permissions granted, start the scan
viewModel.startBleScan()
} else {
Toast.makeText(this, "Bluetooth permissions are required for this app to function.", Toast.LENGTH_LONG).show()
}
}
override fun onBackPressed() {
// If recording is in progress, show a warning dialog
if (viewModel.isRecording.value) {
showExitDialog = true
} else {
returnToSelection()
}
}
private fun returnToSelection() {
// Stop any ongoing operations
viewModel.stopBleScan()
viewModel.disconnectAll()
// Start ParticipantSelectionActivity
val intent = Intent(this, ParticipantSelectionActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
startActivity(intent)
finish()
}
private fun hasRequiredBluetoothPermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
} else {
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
}
}
private fun requestRelevantPermissions() {
val permissionsToRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
requestPermissionLauncher.launch(permissionsToRequest)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Get the number of participants from the intent
val numberOfParticipants = intent.getIntExtra("NUMBER_OF_PARTICIPANTS", 2)
viewModel.numberOfParticipants.value = numberOfParticipants
// Lock orientation to vertical to prevent activity recreation on rotation
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
setContent {
LabRecorderTheme {
if (showExitDialog) {
AlertDialog(
onDismissRequest = { showExitDialog = false },
title = { Text("Recording in Progress") },
text = { Text("Stop recording and return to selection screen?") },
confirmButton = {
TextButton(onClick = {
showExitDialog = false
viewModel.stopRecording()
returnToSelection()
}) {
Text("Yes")
}
},
dismissButton = {
TextButton(onClick = { showExitDialog = false }) {
Text("No")
}
}
)
}
Scaffold { innerPadding ->
MainScreen(
modifier = Modifier.padding(innerPadding),
viewModel = viewModel
)
}
}
}
// Check for permissions and start scanning if granted
if (hasRequiredBluetoothPermissions()) {
viewModel.startBleScan()
} else {
requestRelevantPermissions()
}
}
}
@Composable
fun MainScreen(
modifier: Modifier = Modifier,
viewModel: MainViewModel
) {
// Read state from the ViewModel
val isScanning by viewModel.isScanning
val discoveredDevices = viewModel.discoveredDevices
val connectionStatus1 by viewModel.connectionState1
val connectionStatus2 by viewModel.connectionState2
val hrValue1 by viewModel.hrValue1
val hrValue2 by viewModel.hrValue2
val groupId by viewModel.groupId
val participantId by viewModel.participantId
val isRecording by viewModel.isRecording
val isIntervalRunning by viewModel.isIntervalRunning
val logMessages = viewModel.logMessages
val selectedDevice1 by viewModel.selectedDevice1
val selectedDevice2 by viewModel.selectedDevice2
val numberOfParticipants by viewModel.numberOfParticipants
val dropdownOptions = when {
discoveredDevices.isNotEmpty() -> discoveredDevices
isScanning -> listOf(BleDevice("Scanning...", ""))
else -> listOf(BleDevice("No devices found", ""))
}
var expanded1 by remember { mutableStateOf(false) }
var expanded2 by remember { mutableStateOf(false) }
Column(
modifier = modifier.fillMaxSize().padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// --- Section 1: Setup ---
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (numberOfParticipants == 2) {
OutlinedTextField(
value = groupId,
onValueChange = { viewModel.groupId.value = it },
label = { Text("Group ID *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isRecording
)
} else if (numberOfParticipants == 1) {
OutlinedTextField(
value = participantId,
onValueChange = { viewModel.participantId.value = it },
label = { Text("Participant ID *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isRecording
)
}
Button(
onClick = { if (isScanning) viewModel.stopBleScan() else viewModel.startBleScan() },
modifier = Modifier.fillMaxWidth(),
enabled = !isRecording
) {
if (isScanning) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary)
Spacer(Modifier.width(8.dp))
Text("Stop Scan")
} else {
Text("Scan for Polar Devices")
}
}
}
}
// --- Section 2: Device Connection ---
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
DeviceSelectionDropdown(
label = "Participant 1",
options = dropdownOptions,
selectedOption = selectedDevice1,
onOptionSelected = { viewModel.selectedDevice1.value = it },
expanded = expanded1,
onExpandedChange = { expanded1 = it },
enabled = discoveredDevices.isNotEmpty() && !isRecording,
status = connectionStatus1,
hrValue = hrValue1
)
if (numberOfParticipants == 2) {
DeviceSelectionDropdown(
label = "Participant 2",
options = dropdownOptions,
selectedOption = selectedDevice2,
onOptionSelected = { viewModel.selectedDevice2.value = it },
expanded = expanded2,
onExpandedChange = { expanded2 = it },
enabled = discoveredDevices.isNotEmpty() && !isRecording,
status = connectionStatus2,
hrValue = hrValue2
)
}
Button(
onClick = { viewModel.connectToDevices() },
enabled = (selectedDevice1 != null || selectedDevice2 != null) && !isRecording,
modifier = Modifier.fillMaxWidth()
) {
Text("Connect")
}
}
}
// --- Section 3: Recording Controls ---
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Button(
onClick = { if (isRecording) viewModel.stopRecording() else viewModel.startRecording() },
enabled = (connectionStatus1 == "Connected" || connectionStatus2 == "Connected") &&
((numberOfParticipants == 2 && groupId.isNotBlank()) ||
(numberOfParticipants == 1 && participantId.isNotBlank())),
colors = ButtonDefaults.buttonColors(
containerColor = if (isRecording) Color(0xFFD32F2F) else MaterialTheme.colorScheme.primary
),
modifier = Modifier.fillMaxWidth()
) {
Icon(if (isRecording) Icons.Filled.Stop else Icons.Filled.PlayArrow, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(if (isRecording) "Stop Recording" else "Start Recording")
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
if (isIntervalRunning) {
viewModel.markTimestamp("interval_end")
viewModel.isIntervalRunning.value = false
} else {
viewModel.markTimestamp("interval_start")
viewModel.isIntervalRunning.value = true
}
},
enabled = isRecording,
modifier = Modifier.weight(1f)
) {
Text(if (isIntervalRunning) "Stop Interval" else "Start Interval")
}
Button(
onClick = { viewModel.markTimestamp("manual_mark") },
enabled = isRecording,
modifier = Modifier.weight(1f)
) {
Text("Mark Timestamp")
}
}
}
}
// --- Section 4: Event Log Console ---
LogConsole(logMessages = logMessages, modifier = Modifier.weight(1f))
Text(
text = "Developed by Tom Hempel",
fontSize = 12.sp,
color = LocalContentColor.current.copy(alpha = 0.7f)
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DeviceSelectionDropdown(
label: String,
options: List<BleDevice>,
selectedOption: BleDevice?,
onOptionSelected: (BleDevice) -> Unit,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
enabled: Boolean,
status: String,
hrValue: Int
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
ExposedDropdownMenuBox(
expanded = expanded && enabled,
onExpandedChange = onExpandedChange,
modifier = Modifier.weight(1f)
) {
TextField(
modifier = Modifier.menuAnchor().fillMaxWidth(),
readOnly = true,
value = selectedOption?.name ?: if(enabled) "Select a device" else "Scan first",
onValueChange = {},
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
enabled = enabled
)
ExposedDropdownMenu(
expanded = expanded && enabled,
onDismissRequest = { onExpandedChange(false) },
) {
options.forEach { device ->
if (device.address.isNotEmpty()) {
DropdownMenuItem(
text = { Text(device.name) },
onClick = {
onOptionSelected(device)
onExpandedChange(false)
}
)
}
}
}
}
Spacer(Modifier.width(16.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(100.dp)) {
val statusColor = when(status) {
"Connected" -> Color(0xFF2E7D32) // Dark Green
"Error" -> MaterialTheme.colorScheme.error
else -> LocalContentColor.current.copy(alpha = 0.7f)
}
Text(status, fontSize = 14.sp, color = statusColor, fontWeight = FontWeight.Bold)
if (status == "Connected") {
Text(
text = "$hrValue BPM",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
@Composable
fun LogConsole(logMessages: List<String>, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(logMessages.size) {
if (logMessages.isNotEmpty()) {
coroutineScope.launch {
listState.animateScrollToItem(0)
}
}
}
OutlinedCard(modifier.fillMaxWidth()) {
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text("Event Log", style = MaterialTheme.typography.titleMedium)
Divider(modifier = Modifier.padding(vertical = 4.dp))
LazyColumn(state = listState, reverseLayout = true, modifier = Modifier.fillMaxSize()) {
items(logMessages) { msg ->
val color = when {
msg.contains("ERROR") -> MaterialTheme.colorScheme.error
msg.contains("Connected") || msg.contains("Recording started") -> Color(0xFF2E7D32) // Dark Green
msg.contains("Stopping") || msg.contains("Disconnected") -> Color(0xFFC62828) // Dark Red
else -> LocalContentColor.current
}
Text(
text = msg,
color = color,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(bottom = 2.dp)
)
}
}
}
}
}
@Composable
fun ParticipantSelectionDialog(
onDismiss: (Int) -> Unit
) {
AlertDialog(
onDismissRequest = { /* Do nothing, force selection */ },
title = { Text("Select Number of Participants") },
text = { Text("Please select the number of participants for this recording session.") },
confirmButton = {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Button(
onClick = { onDismiss(1) },
modifier = Modifier.weight(1f).padding(end = 8.dp)
) {
Text("1 Participant")
}
Button(
onClick = { onDismiss(2) },
modifier = Modifier.weight(1f).padding(start = 8.dp)
) {
Text("2 Participants")
}
}
},
dismissButton = null // No dismiss button to force selection
)
}

View File

@ -0,0 +1,181 @@
package com.tomhempel.labrecorder
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tomhempel.labrecorder.ui.theme.LabRecorderTheme
class ParticipantSelectionActivity : ComponentActivity() {
private var showExitDialog by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
LabRecorderTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
if (showExitDialog) {
AlertDialog(
onDismissRequest = { showExitDialog = false },
title = { Text("Exit App") },
text = { Text("Do you want to exit the app?") },
confirmButton = {
TextButton(onClick = {
showExitDialog = false
finish()
}) {
Text("Yes")
}
},
dismissButton = {
TextButton(onClick = { showExitDialog = false }) {
Text("No")
}
}
)
}
ParticipantSelectionScreen(
onSelectionMade = { numberOfParticipants ->
val intent = Intent(this, MainActivity::class.java).apply {
putExtra("NUMBER_OF_PARTICIPANTS", numberOfParticipants)
}
startActivity(intent)
finish() // Close this activity
},
onInspectData = {
val intent = Intent(this, DataInspectionActivity::class.java)
startActivity(intent)
}
)
}
}
}
}
override fun onBackPressed() {
showExitDialog = true
}
}
@Composable
fun ParticipantSelectionScreen(
onSelectionMade: (Int) -> Unit,
onInspectData: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Logo
Image(
painter = painterResource(id = R.mipmap.logo),
contentDescription = "App Logo",
modifier = Modifier.size(120.dp)
)
Spacer(modifier = Modifier.height(24.dp))
// Title
Text(
text = "Lab Recorder",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
// Description
Text(
text = "Select the number of participants for this recording session",
fontSize = 16.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp)
)
Spacer(modifier = Modifier.height(32.dp))
// Selection Buttons
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Button(
onClick = { onSelectionMade(1) },
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.padding(horizontal = 32.dp)
) {
Text("Single Participant", fontSize = 18.sp)
}
Button(
onClick = { onSelectionMade(2) },
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.padding(horizontal = 32.dp)
) {
Text("Two Participants", fontSize = 18.sp)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Developed by Tom Hempel",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = LocalContentColor.current.copy(alpha = 0.7f),
textAlign = TextAlign.Center
)
}
// Divider and Data Inspection Section
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 30.dp) // Added extra padding at the bottom
) {
HorizontalDivider(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
thickness = 2.dp
)
OutlinedButton(
onClick = onInspectData,
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.padding(horizontal = 32.dp)
) {
Text("Inspect Recorded Data", fontSize = 16.sp)
}
}
}
}

View File

@ -0,0 +1,452 @@
package com.tomhempel.labrecorder
import android.graphics.Color
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.github.mikephil.charting.charts.LineChart
import com.github.mikephil.charting.components.Legend
import com.github.mikephil.charting.components.XAxis
import com.github.mikephil.charting.data.Entry
import com.github.mikephil.charting.data.LineData
import com.github.mikephil.charting.data.LineDataSet
import java.io.File
import java.io.FileReader
import java.io.BufferedReader
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.cardview.widget.CardView
import androidx.core.view.updateLayoutParams
import com.github.mikephil.charting.components.YAxis
import com.github.mikephil.charting.highlight.Highlight
import com.github.mikephil.charting.listener.OnChartValueSelectedListener
import com.github.mikephil.charting.interfaces.datasets.ILineDataSet
class TimeNormalizedPlotActivity : AppCompatActivity() {
private lateinit var hrChart: LineChart
private lateinit var rrChart: LineChart
private val intervalColors = listOf(
Color.argb(128, 128, 128, 128), // Gray
Color.argb(128, 128, 64, 64), // Dark Red
Color.argb(128, 64, 128, 64), // Dark Green
Color.argb(128, 64, 64, 128), // Dark Blue
Color.argb(128, 128, 128, 64), // Dark Yellow
Color.argb(128, 128, 64, 128) // Dark Purple
)
data class TimestampEvent(
val timestamp: Long,
val eventType: String
)
data class DataPoint(
val timestamp: Long,
val value: Float
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Create ScrollView for scrollable content
val scrollView = android.widget.ScrollView(this).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT
)
isFillViewport = true
}
// Create layout programmatically
val rootLayout = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
setPadding(16, 32, 16, 32) // Added top and bottom padding
}
// Create stats cards container
val statsContainer = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 0, 0, 16)
}
weightSum = 2f
}
// Create stats cards for each participant
val participant1Stats = CardView(this).apply {
layoutParams = LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
).apply {
setMargins(0, 0, 8, 0)
}
radius = 12f
cardElevation = 8f
setCardBackgroundColor(Color.parseColor("#1F1F1F"))
}
val participant2Stats = CardView(this).apply {
layoutParams = LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
).apply {
setMargins(8, 0, 0, 0)
}
radius = 12f
cardElevation = 8f
setCardBackgroundColor(Color.parseColor("#1F1F1F"))
}
// Create TextViews for stats with better styling
val participant1Text = TextView(this).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
setPadding(20, 20, 20, 20)
textSize = 14f
setTextColor(Color.WHITE)
setLineSpacing(4f, 1.2f)
}
val participant2Text = TextView(this).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
setPadding(20, 20, 20, 20)
textSize = 14f
setTextColor(Color.WHITE)
setLineSpacing(4f, 1.2f)
}
participant1Stats.addView(participant1Text)
participant2Stats.addView(participant2Text)
statsContainer.addView(participant1Stats)
statsContainer.addView(participant2Stats)
rootLayout.addView(statsContainer)
// Create HR Chart Card
val hrCard = CardView(this).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0,
1f
).apply {
setMargins(0, 0, 0, 16)
}
radius = 8f
cardElevation = 4f
}
hrChart = LineChart(this).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT
)
description.isEnabled = false
}
hrCard.addView(hrChart)
rootLayout.addView(hrCard)
// Create RR Chart Card
val rrCard = CardView(this).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0,
1f
).apply {
setMargins(0, 0, 0, 16)
}
radius = 8f
cardElevation = 4f
}
rrChart = LineChart(this).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT
)
description.isEnabled = false
}
rrCard.addView(rrChart)
rootLayout.addView(rrCard)
// Add ScrollView to the content
scrollView.addView(rootLayout)
setContentView(scrollView)
// Configure charts
setupChart(hrChart, "Heart Rate (BPM)")
setupChart(rrChart, "RR Interval (ms)")
// Load and display data
val recordingPath = intent.getStringExtra("RECORDING_PATH") ?: return
loadAndDisplayData(recordingPath, participant1Text, participant2Text)
}
private fun setupChart(chart: LineChart, yAxisLabel: String) {
chart.apply {
// Disable interactions
setTouchEnabled(false)
setPinchZoom(false)
setScaleEnabled(false)
isDragEnabled = false
// Configure legend
legend.apply {
horizontalAlignment = Legend.LegendHorizontalAlignment.CENTER
verticalAlignment = Legend.LegendVerticalAlignment.TOP
orientation = Legend.LegendOrientation.HORIZONTAL
setDrawInside(false)
form = Legend.LegendForm.LINE
}
// Configure axes
xAxis.apply {
position = XAxis.XAxisPosition.BOTTOM
setDrawGridLines(true)
labelRotationAngle = 0f
axisMinimum = 0f
textSize = 12f
valueFormatter = object : com.github.mikephil.charting.formatter.ValueFormatter() {
override fun getFormattedValue(value: Float): String {
return "${value.toInt()}s"
}
}
}
axisLeft.apply {
setDrawGridLines(true)
textSize = 12f
// Fix: Use description instead of axisLabel
description.text = yAxisLabel
}
axisRight.isEnabled = false
// Add padding for better visibility
setExtraOffsets(8f, 16f, 8f, 8f)
}
}
private fun loadAndDisplayData(
recordingPath: String,
participant1Text: TextView,
participant2Text: TextView
) {
val recordingDir = File(recordingPath)
val timestampsFile = File(recordingDir, "timestamps.csv")
// Load global events
val events = loadTimestamps(timestampsFile)
val firstTimestamp = events.minOfOrNull { it.timestamp } ?: 0L
// Load participant data
val participants = listOf("Participant_1", "Participant_2")
val participantColors = listOf(Color.BLUE, Color.RED)
val hrDataSets = mutableListOf<ILineDataSet>()
val rrDataSets = mutableListOf<ILineDataSet>()
val statsBuilders = Array(2) { StringBuilder() }
participants.forEachIndexed { index, participant ->
val participantDir = File(recordingDir, participant)
val hrFile = File(participantDir, "hr.csv")
val rrFile = File(participantDir, "rr.csv")
statsBuilders[index].append("$participant\n\n")
// Load HR data
val hrData = loadDataPoints(hrFile)
if (hrData.isNotEmpty()) {
val hrEntries = hrData.map {
Entry(
(it.timestamp - firstTimestamp) / 1000f,
it.value
)
}
val hrDataSet = LineDataSet(hrEntries, "$participant HR").apply {
color = participantColors[index]
setDrawCircles(true)
circleRadius = 2f
circleColors = listOf(participantColors[index])
lineWidth = 2f
mode = LineDataSet.Mode.LINEAR
}
hrDataSets.add(hrDataSet)
// Calculate statistics
statsBuilders[index].append("Heart Rate:\n")
statsBuilders[index].append(" Min: ${hrData.minOf { it.value }.toInt()} BPM\n")
statsBuilders[index].append(" Max: ${hrData.maxOf { it.value }.toInt()} BPM\n")
statsBuilders[index].append(" Avg: ${hrData.map { it.value }.average().toInt()} BPM\n\n")
}
// Load RR data
val rrData = loadDataPoints(rrFile)
if (rrData.isNotEmpty()) {
val rrEntries = rrData.map {
Entry(
(it.timestamp - firstTimestamp) / 1000f,
it.value
)
}
val rrDataSet = LineDataSet(rrEntries, "$participant RR").apply {
color = participantColors[index]
setDrawCircles(true)
circleRadius = 2f
circleColors = listOf(participantColors[index])
lineWidth = 2f
mode = LineDataSet.Mode.LINEAR
}
rrDataSets.add(rrDataSet)
// Calculate statistics
statsBuilders[index].append("RR Intervals:\n")
statsBuilders[index].append(" Min: ${rrData.minOf { it.value }.toInt()} ms\n")
statsBuilders[index].append(" Max: ${rrData.maxOf { it.value }.toInt()} ms\n")
statsBuilders[index].append(" Avg: ${rrData.map { it.value }.average().toInt()} ms")
}
}
// Update statistics
participant1Text.text = statsBuilders[0].toString()
participant2Text.text = statsBuilders[1].toString()
// Set chart data
hrChart.setData(LineData(hrDataSets))
rrChart.setData(LineData(rrDataSets))
// Add event markers and intervals
addEventMarkersAndIntervals(events, firstTimestamp, hrChart)
addEventMarkersAndIntervals(events, firstTimestamp, rrChart)
// Refresh charts
hrChart.invalidate()
rrChart.invalidate()
}
private fun loadTimestamps(file: File): List<TimestampEvent> {
if (!file.exists()) return emptyList()
return try {
BufferedReader(FileReader(file)).useLines { lines ->
lines.drop(1) // Skip header
.map { line ->
val parts = line.split(",")
TimestampEvent(
timestamp = parts[0].toLong(),
eventType = parts[1]
)
}.toList()
}
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
private fun loadDataPoints(file: File): List<DataPoint> {
if (!file.exists()) return emptyList()
return try {
BufferedReader(FileReader(file)).useLines { lines ->
lines.drop(1) // Skip header
.map { line ->
val parts = line.split(",")
DataPoint(
timestamp = parts[0].toLong(),
value = parts[1].toFloat()
)
}.toList()
}
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
private fun addEventMarkersAndIntervals(
events: List<TimestampEvent>,
firstTimestamp: Long,
chart: LineChart
) {
var currentIntervalStart: Float? = null
var intervalIndex = 0
val intervalDataSets = mutableListOf<ILineDataSet>()
events.forEach { event ->
val normalizedTime = (event.timestamp - firstTimestamp) / 1000f
when (event.eventType) {
"manual_mark" -> {
// Add vertical line for manual mark - white and prominent
val line = com.github.mikephil.charting.components.LimitLine(normalizedTime).apply {
lineWidth = 1f
lineColor = Color.WHITE
enableDashedLine(15f, 8f, 0f)
label = "" // No label
}
chart.xAxis.addLimitLine(line)
}
"interval_start" -> {
currentIntervalStart = normalizedTime
}
"interval_end" -> {
currentIntervalStart?.let { start ->
// Create filled area for interval using a background dataset
var color = intervalColors[intervalIndex % intervalColors.size]
// Create entries for the interval area
val entries = listOf(
Entry(start, chart.axisLeft.axisMinimum),
Entry(start, chart.axisLeft.axisMaximum),
Entry(normalizedTime, chart.axisLeft.axisMaximum),
Entry(normalizedTime, chart.axisLeft.axisMinimum)
)
val intervalDataSet = LineDataSet(entries, "Interval ${intervalIndex + 1}").apply {
setDrawFilled(true)
fillColor = color
fillAlpha = 128
color = Color.TRANSPARENT
setDrawCircles(false)
setDrawValues(false)
isHighlightEnabled = false
axisDependency = YAxis.AxisDependency.LEFT
}
intervalDataSets.add(intervalDataSet)
intervalIndex++
}
currentIntervalStart = null
}
}
}
// Add interval datasets to the chart
if (intervalDataSets.isNotEmpty()) {
// Get current datasets
val currentDataSets = chart.data?.dataSets?.toMutableList() ?: mutableListOf()
// Create combined dataset list
val allDataSets = mutableListOf<ILineDataSet>()
allDataSets.addAll(intervalDataSets)
allDataSets.addAll(currentDataSets)
// Create and set new data
val newData = LineData(allDataSets)
chart.setData(newData)
chart.invalidate()
}
}
}

View File

@ -0,0 +1,11 @@
package com.tomhempel.labrecorder.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View File

@ -0,0 +1,57 @@
package com.tomhempel.labrecorder.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun LabRecorderTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@ -0,0 +1,34 @@
package com.tomhempel.labrecorder.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".AnalyseActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
<include layout="@layout/content_analyse" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="@dimen/fab_margin"
android:layout_marginBottom="16dp"
app:srcCompat="@android:drawable/ic_dialog_email" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<fragment
android:id="@+id/nav_host_fragment_content_analyse"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/nav_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".FirstFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<Button
android:id="@+id/button_first"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/next"
app:layout_constraintBottom_toTopOf="@id/textview_first"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/textview_first"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/lorem_ipsum"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/button_first" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".SecondFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<Button
android:id="@+id/button_second"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/previous"
app:layout_constraintBottom_toTopOf="@id/textview_second"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/textview_second"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/lorem_ipsum"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/button_second" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/FirstFragment">
<fragment
android:id="@+id/FirstFragment"
android:name="com.tomhempel.labrecorder.FirstFragment"
android:label="@string/first_fragment_label"
tools:layout="@layout/fragment_first">
<action
android:id="@+id/action_FirstFragment_to_SecondFragment"
app:destination="@id/SecondFragment" />
</fragment>
<fragment
android:id="@+id/SecondFragment"
android:name="com.tomhempel.labrecorder.SecondFragment"
android:label="@string/second_fragment_label"
tools:layout="@layout/fragment_second">
<action
android:id="@+id/action_SecondFragment_to_FirstFragment"
app:destination="@id/FirstFragment" />
</fragment>
</navigation>

View File

@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">48dp</dimen>
</resources>

View File

@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.LabRecorder" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your dark theme here. -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
</style>
</resources>

View File

@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.LabRecorder" parent="Base.Theme.LabRecorder">
<!-- Transparent system bars for edge-to-edge. -->
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">?attr/isLightTheme</item>
</style>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">200dp</dimen>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">48dp</dimen>
</resources>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">16dp</dimen>
</resources>

View File

@ -0,0 +1,45 @@
<resources>
<string name="app_name">Lab Recorder</string>
<!-- Strings used for fragments for navigation -->
<string name="first_fragment_label">First Fragment</string>
<string name="second_fragment_label">Second Fragment</string>
<string name="next">Next</string>
<string name="previous">Previous</string>
<string name="lorem_ipsum">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam in scelerisque sem. Mauris
volutpat, dolor id interdum ullamcorper, risus dolor egestas lectus, sit amet mattis purus
dui nec risus. Maecenas non sodales nisi, vel dictum dolor. Class aptent taciti sociosqu ad
litora torquent per conubia nostra, per inceptos himenaeos. Suspendisse blandit eleifend
diam, vel rutrum tellus vulputate quis. Aliquam eget libero aliquet, imperdiet nisl a,
ornare ex. Sed rhoncus est ut libero porta lobortis. Fusce in dictum tellus.\n\n
Suspendisse interdum ornare ante. Aliquam nec cursus lorem. Morbi id magna felis. Vivamus
egestas, est a condimentum egestas, turpis nisl iaculis ipsum, in dictum tellus dolor sed
neque. Morbi tellus erat, dapibus ut sem a, iaculis tincidunt dui. Interdum et malesuada
fames ac ante ipsum primis in faucibus. Curabitur et eros porttitor, ultricies urna vitae,
molestie nibh. Phasellus at commodo eros, non aliquet metus. Sed maximus nisl nec dolor
bibendum, vel congue leo egestas.\n\n
Sed interdum tortor nibh, in sagittis risus mollis quis. Curabitur mi odio, condimentum sit
amet auctor at, mollis non turpis. Nullam pretium libero vestibulum, finibus orci vel,
molestie quam. Fusce blandit tincidunt nulla, quis sollicitudin libero facilisis et. Integer
interdum nunc ligula, et fermentum metus hendrerit id. Vestibulum lectus felis, dictum at
lacinia sit amet, tristique id quam. Cras eu consequat dui. Suspendisse sodales nunc ligula,
in lobortis sem porta sed. Integer id ultrices magna, in luctus elit. Sed a pellentesque
est.\n\n
Aenean nunc velit, lacinia sed dolor sed, ultrices viverra nulla. Etiam a venenatis nibh.
Morbi laoreet, tortor sed facilisis varius, nibh orci rhoncus nulla, id elementum leo dui
non lorem. Nam mollis ipsum quis auctor varius. Quisque elementum eu libero sed commodo. In
eros nisl, imperdiet vel imperdiet et, scelerisque a mauris. Pellentesque varius ex nunc,
quis imperdiet eros placerat ac. Duis finibus orci et est auctor tincidunt. Sed non viverra
ipsum. Nunc quis augue egestas, cursus lorem at, molestie sem. Morbi a consectetur ipsum, a
placerat diam. Etiam vulputate dignissim convallis. Integer faucibus mauris sit amet finibus
convallis.\n\n
Phasellus in aliquet mi. Pellentesque habitant morbi tristique senectus et netus et
malesuada fames ac turpis egestas. In volutpat arcu ut felis sagittis, in finibus massa
gravida. Pellentesque id tellus orci. Integer dictum, lorem sed efficitur ullamcorper,
libero justo consectetur ipsum, in mollis nisl ex sed nisl. Donec maximus ullamcorper
sodales. Praesent bibendum rhoncus tellus nec feugiat. In a ornare nulla. Donec rhoncus
libero vel nunc consequat, quis tincidunt nisl eleifend. Cras bibendum enim a justo luctus
vestibulum. Fusce dictum libero quis erat maximus, vitae volutpat diam dignissim.
</string>
</resources>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.LabRecorder" parent="android:Theme.Material.Light.NoActionBar" />
<!-- Base application theme. -->
<style name="Base.Theme.LabRecorder" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
</style>
</resources>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@ -0,0 +1,17 @@
package com.tomhempel.labrecorder
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

6
Code/build.gradle.kts Normal file
View File

@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}

File diff suppressed because one or more lines are too long

23
Code/gradle.properties Normal file
View File

@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

View File

@ -0,0 +1,42 @@
[versions]
agp = "8.11.1"
kotlin = "2.0.21"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
composeBom = "2024.09.00"
material = "1.10.0"
appcompat = "1.6.1"
constraintlayout = "2.1.4"
navigationFragmentKtx = "2.6.0"
navigationUiKtx = "2.6.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigationFragmentKtx" }
androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigationUiKtx" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

BIN
Code/gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,6 @@
#Sat Jul 19 17:27:31 CEST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
Code/gradlew vendored Normal file
View File

@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
Code/gradlew.bat vendored Normal file
View File

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

18
Code/settings.gradle.kts Normal file
View File

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "LabRecorder"
include(":app")