Compare commits
98 Commits
5c6acf9d60
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 341d663b36 | |||
| cbc03c0674 | |||
| bd8ce61897 | |||
| 0da90f976f | |||
| 6ae62a60fc | |||
| afa6ac22ba | |||
| d5c8dccc34 | |||
| 82bc0c78fe | |||
| 4b47611902 | |||
| 0b4931eaca | |||
| 6c7a78ce76 | |||
| be5a5db18a | |||
| e722680863 | |||
| 0d8415ba4e | |||
| b3055d8f1a | |||
| efc007e487 | |||
| 469535f79a | |||
| 11c59b890e | |||
| 17f605cf5f | |||
| 07896959ce | |||
| a52b58c176 | |||
| 2134a53921 | |||
| 41fc6fdd86 | |||
| ab53bee5c4 | |||
| 2db7eea9f1 | |||
| 1f91d7508e | |||
| 5de087e8e0 | |||
| a3a15b0644 | |||
| 68398e5b60 | |||
| 8cf24d8c2a | |||
| 76d97e202b | |||
| 90b0a19a4d | |||
| 56dc8337af | |||
| 6b611b1d09 | |||
| cb86d52705 | |||
| ea44f0bba4 | |||
| 984e8715d7 | |||
| d6b7504408 | |||
| ad28cf2360 | |||
| bb31dcee00 | |||
| 58251e21cc | |||
| 486c194f08 | |||
| 33841516f1 | |||
| f41eda6f62 | |||
| 02db0d849c | |||
| a583ef64a1 | |||
| 8432bbb986 | |||
| d40ea1e537 | |||
| d88bddbf75 | |||
| bbf1638f20 | |||
| 010e11cf6b | |||
| 1b34b0e974 | |||
| 2364d0d327 | |||
| 0278acc0fc | |||
| 60d1a75838 | |||
| 3ab586956e | |||
| 5ea0cdde63 | |||
| 03556ff1d4 | |||
| 9ccdf846f0 | |||
| 8cb3cf769d | |||
| 94a573f218 | |||
| a5aa3cc9d7 | |||
| fbcb5c9543 | |||
| cf9f42b66d | |||
| 149736108e | |||
| 01a0ac68a4 | |||
| 1bc112047d | |||
| 8601bb5ba3 | |||
| 2eb6292dc2 | |||
| d76627ebc3 | |||
| a76724a3d5 | |||
| b86ee54934 | |||
| 366cc94d86 | |||
| 416c447ad8 | |||
| 30a7146929 | |||
| 5cfee2aa66 | |||
| 9805bdf175 | |||
| 1b163891e0 | |||
| 372a293bd0 | |||
| 72a06f542b | |||
| 00b9ceb467 | |||
| 4ab5e52259 | |||
| 779f66b2bb | |||
| c2e8b8c212 | |||
| d61b8f0bd8 | |||
| 5eb1113c1a | |||
| 0c19247838 | |||
| c0baa673b8 | |||
| a94e0d5d63 | |||
| 2bb083fd7d | |||
| be663282d7 | |||
| a4ecb0f132 | |||
| 2c36ac005d | |||
| 13e6e640d3 | |||
| db5ec99190 | |||
| 0a6f3bdaae | |||
| 5a0d74baf0 | |||
| 45f1db3b37 |
75
.claude/settings.local.json
Normal file
75
.claude/settings.local.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(./gradlew assembleDebug:*)",
|
||||
"Bash(./gradlew installDebug:*)",
|
||||
"Bash(adb logcat:*)",
|
||||
"Bash(adb shell am start:*)",
|
||||
"Bash(adb devices:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(cmake:*)",
|
||||
"Bash(\".\\\\build\\\\Debug\\\\mosis-designer.exe\" \"..\\\\src\\\\main\\\\assets\\\\apps\\\\home\\\\home.rml\")",
|
||||
"Bash(dir:*)",
|
||||
"Bash(start \"\" \".\\\\build\\\\Debug\\\\mosis-designer.exe\" \"..\\\\src\\\\main\\\\assets\\\\apps\\\\home\\\\home.rml\")",
|
||||
"Bash(\"C:\\\\Program Files\\\\AutoHotkey\\\\v2\\\\AutoHotkey64.exe\" \"D:\\\\Dev\\\\Mosis\\\\MosisService\\\\designer\\\\test\\\\diagnose.ahk\")",
|
||||
"Bash(tasklist:*)",
|
||||
"Bash(\"C:\\\\Program Files\\\\AutoHotkey\\\\v2\\\\AutoHotkey64.exe\" /ErrorStdOut diagnose.ahk)",
|
||||
"Bash(taskkill:*)",
|
||||
"Bash(\"C:\\\\Program Files\\\\AutoHotkey\\\\v2\\\\AutoHotkey64.exe\" full_click_test.ahk)",
|
||||
"Bash(\"C:\\\\Program Files\\\\AutoHotkey\\\\v2\\\\AutoHotkey64.exe\" visual_click_test.ahk)",
|
||||
"Bash(\".\\\\build\\\\Debug\\\\designer-test.exe\" --designer \"..\\\\designer\\\\build\\\\Debug\\\\mosis-designer.exe\" --document \"..\\\\src\\\\main\\\\assets\\\\apps\\\\home\\\\home.rml\")",
|
||||
"Bash(./designer-test.exe)",
|
||||
"Bash(\"D:\\\\Dev\\\\Mosis\\\\MosisService\\\\designer-test\\\\build\\\\Debug\\\\designer-test.exe\")",
|
||||
"Bash(git add:*)",
|
||||
"Bash(adb install:*)",
|
||||
"Bash(adb shell am broadcast:*)",
|
||||
"Bash(timeout 5 ./mosis-designer.exe:*)",
|
||||
"Bash(.buildDebugmosis-designer.exe --dump D:DevMosisMosisServicesrcmainassetsappshomehome.rml)",
|
||||
"Bash(cmd /c \"cd /d D:\\\\Dev\\\\Mosis\\\\MosisService\\\\designer && .\\\\build\\\\Debug\\\\mosis-designer.exe --dump ..\\\\src\\\\main\\\\assets\\\\apps\\\\home\\\\home.rml 2>&1\")",
|
||||
"Bash(start /min cmd /c \".\\\\build\\\\Debug\\\\mosis-designer.exe ..\\\\src\\\\main\\\\assets\\\\apps\\\\home\\\\home.rml --hierarchy hierarchy.json\")",
|
||||
"Bash(powershell -Command:*)",
|
||||
"Bash(adb shell \"ps -A | grep mosis\")",
|
||||
"Bash(adb shell:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(magick:*)",
|
||||
"Bash(fc:*)",
|
||||
"Bash(cmp:*)",
|
||||
"Bash(sort:*)",
|
||||
"Bash(vcpkg search:*)",
|
||||
"Bash(D:/vcpkg/vcpkg search glad)",
|
||||
"Bash(timeout 8 ./mosis-designer.exe:*)",
|
||||
"Bash(start mosis-designer.exe apps/home/home.rml)",
|
||||
"Bash(./mosis-designer.exe:*)",
|
||||
"Bash(cmd /c \"cd /d D:\\\\Dev\\\\Mosis\\\\MosisService\\\\designer && cmake --build build --config Debug\")",
|
||||
"Bash(cmd /c:*)",
|
||||
"Bash(\"D:/Dev/Mosis/MosisService/designer-test/build/Debug/designer-test.exe\")",
|
||||
"Bash(python:*)",
|
||||
"Bash(./gradlew connectedAndroidTest:*)",
|
||||
"Bash(\"D:\\\\Epic\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\Build.bat\" MosisUnreal Android Development -Project=\"D:\\\\Dev\\\\Mosis\\\\MosisUnreal\\\\MosisUnreal.uproject\")",
|
||||
"Bash(\"D:\\\\Epic\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\Build.bat\" MosisUnreal Win64 Development -Project=\"D:\\\\Dev\\\\Mosis\\\\MosisUnreal\\\\MosisUnreal.uproject\")",
|
||||
"Bash(D:)",
|
||||
"Bash(\"D:\\\\Epic\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\Build.bat\" MosisUnrealEditor Win64 Development -Project=\"D:\\\\Dev\\\\Mosis\\\\MosisUnreal\\\\MosisUnreal.uproject\")",
|
||||
"Bash(\"D:\\\\Epic\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\RunUAT.bat\" BuildCookRun -project=\"D:\\\\Dev\\\\Mosis\\\\MosisUnreal\\\\MosisUnreal.uproject\" -platform=Android -clientconfig=Development -build -noP4 -utf8output)",
|
||||
"Bash(\"D:\\\\Epic\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\RunUAT.bat\" BuildCookRun -project=\"D:\\\\Dev\\\\Mosis\\\\MosisUnreal\\\\MosisUnreal.uproject\" -platform=Android -clientconfig=Development -build -cook -stage -pak -package -noP4 -utf8output)",
|
||||
"Bash(./gradlew.bat:*)",
|
||||
"Bash(gradle assembleRelease:*)",
|
||||
"Bash(\"C:\\\\Program Files\\\\Unity\\\\Hub\\\\Editor\\\\6000.3.2f1\\\\Editor\\\\Unity.exe\" -batchmode -quit -nographics -projectPath \"D:\\\\Dev\\\\Mosis\\\\MosisVR\" -executeMethod BuildScript.BuildAndroidCI -outputPath \"D:\\\\Dev\\\\Mosis\\\\Builds\\\\Unity\\\\Android\\\\MosisVR.apk\" -logFile \"D:\\\\Dev\\\\Mosis\\\\Builds\\\\Unity\\\\build3.log\")",
|
||||
"Bash(gradlew.bat assembleRelease)",
|
||||
"Bash(\"C:\\\\Program Files\\\\Unity\\\\Hub\\\\Editor\\\\6000.3.2f1\\\\Editor\\\\Unity.exe\" -batchmode -quit -nographics -projectPath \"D:\\\\Dev\\\\Mosis\\\\MosisVR\" -executeMethod BuildScript.BuildAndroidDirectCI -outputPath \"D:\\\\Dev\\\\Mosis\\\\Builds\\\\Unity\\\\Android\\\\MosisVR-direct.apk\" -logFile \"D:\\\\Dev\\\\Mosis\\\\Builds\\\\Unity\\\\build-direct.log\")",
|
||||
"Bash(gradlew assembleDebug:*)",
|
||||
"Bash(\"D:\\\\Epic\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\RunUAT.bat\" BuildCookRun -project=\"D:\\\\Dev\\\\Mosis\\\\MosisUnreal\\\\MosisUnreal.uproject\" -platform=Android -clientconfig=Development -build -cook -stage -pak -package -archive -archivedirectory=\"D:\\\\Dev\\\\Mosis\\\\Builds\\\\Unreal\" -noP4)",
|
||||
"Bash(adb:*)",
|
||||
"Bash(\"D:/Epic/UE_5.5/Engine/Build/BatchFiles/RunUAT.bat\" BuildCookRun -project=\"D:/Dev/Mosis/MosisUnreal/MosisUnreal.uproject\" -platform=Android -clientconfig=Development -build -cook -stage -pak -package -noP4)",
|
||||
"Bash(MSYS_NO_PATHCONV=1 adb:*)",
|
||||
"Bash(\"D:/Epic/UE_5.5/Engine/Build/BatchFiles/Build.bat\" MosisUnrealEditor Win64 Development -Project=\"D:/Dev/Mosis/MosisUnreal/MosisUnreal.uproject\")",
|
||||
"Bash(\"D:/Epic/UE_5.5/Engine/Build/BatchFiles/Build.bat\" MosisUnreal Android Development -Project=\"D:/Dev/Mosis/MosisUnreal/MosisUnreal.uproject\")",
|
||||
"Bash(\"D:/Epic/UE_5.5/Engine/Build/BatchFiles/RunUAT.bat\" BuildCookRun -project=\"D:/Dev/Mosis/MosisUnreal/MosisUnreal.uproject\" -platform=Android -clientconfig=Development -build -cook -stage -pak -package -noP4 -utf8output)",
|
||||
"Bash(git -C \"D:/Dev/Mosis/MosisUnreal\" add Plugins/MosisSDK/MosisSDK.uplugin Plugins/MosisSDK/Source/MosisSDK/MosisSDK.Build.cs Plugins/MosisSDK/Source/MosisSDK/Private/MosisPhoneActor.cpp Plugins/MosisSDK/Source/MosisSDK/Private/MosisPointerComponent.cpp Plugins/MosisSDK/Source/MosisSDK/Public/MosisPointerComponent.h)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"D:\\Dev\\Mosis\\MosisUnreal",
|
||||
"D:\\Dev\\Mosis\\MosisVR"
|
||||
]
|
||||
}
|
||||
}
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -6,3 +6,25 @@ build
|
||||
.cxx
|
||||
.DS_Store
|
||||
/designer/test/*test_result.txt
|
||||
/sandbox-test/test_results.json
|
||||
|
||||
# Test output files
|
||||
/tests/
|
||||
*.png
|
||||
test_*.json
|
||||
test_*.txt
|
||||
test_*.log
|
||||
*_hierarchy.json
|
||||
recorded_actions.json
|
||||
|
||||
# Junctions to src/main/assets/ for base-apps testing
|
||||
# Create with: mklink /J icons src\main\assets\icons (etc.)
|
||||
/icons
|
||||
/scripts
|
||||
/ui
|
||||
|
||||
# Sandbox data created during testing
|
||||
/src/main/assets/sandbox_data/
|
||||
|
||||
# Misc
|
||||
NUL
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# MosisService Architecture
|
||||
|
||||
## Overview
|
||||
MosisService is an Android application that combines Kotlin UI components with native C++ libraries for UI rendering and system interaction. The architecture is built around a service-oriented design using Android's Binder system and integrates with RmlUi for rich UI rendering.
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. Service Layer (mosis-service)
|
||||
- **Purpose**: Main Android Binder service implementation
|
||||
- **Interface**: Implements `IMosisService.aidl`
|
||||
- **Functionality**:
|
||||
- Touch event processing (onTouchDown, onTouchMove, onTouchUp)
|
||||
- Service initialization and listener management
|
||||
- Integration with kernel-based rendering system
|
||||
- Asset loading through Android AssetManager
|
||||
|
||||
### 2. Rendering Layer (mosis-test)
|
||||
- **Purpose**: UI rendering and testing infrastructure
|
||||
- **Interface**: Implements `IMosisListener.aidl`
|
||||
- **Functionality**:
|
||||
- OpenGL ES 2.0 rendering pipeline using GLAD
|
||||
- Multi-threaded rendering with EGL context management
|
||||
- Surface and buffer handling for Android native windows
|
||||
- Task queue management for asynchronous rendering operations
|
||||
- Hardware buffer management and processing
|
||||
|
||||
### 3. Core Engine (Kernel)
|
||||
- **Purpose**: Central rendering and event processing engine
|
||||
- **Components**:
|
||||
- RmlUi-based UI rendering engine
|
||||
- EGL context creation and management
|
||||
- Render target handling
|
||||
- Touch event processing and UI updates
|
||||
- Multi-threaded execution using std::thread
|
||||
|
||||
### 4. Supporting Libraries
|
||||
- **AssetsManager**: Asset loading from Android AssetManager
|
||||
- **Logger**: Cross-platform logging system
|
||||
- **EGL Context**: OpenGL ES context management
|
||||
- **Render Target**: Framebuffer and buffer management
|
||||
- **Shader**: OpenGL shader program handling
|
||||
- **External Texture**: Hardware buffer texture creation
|
||||
|
||||
## System Architecture
|
||||
|
||||
### Android Binder Integration
|
||||
- Uses AIDL (Android Interface Definition Language) for cross-process communication
|
||||
- Service exposes `IMosisService` interface for touch events and initialization
|
||||
- Listener pattern with `IMosisListener` for rendering callbacks
|
||||
- Binds to Java/Kotlin components through JNI
|
||||
|
||||
### Multi-threading Model
|
||||
- Service layer runs in main thread for event handling
|
||||
- Rendering loop runs in dedicated thread managed by Kernel
|
||||
- Async task processing for UI updates
|
||||
- Thread-safe communication between components
|
||||
|
||||
### Rendering Pipeline
|
||||
1. **Initialization**: Service connects to test layer, creates EGL context
|
||||
2. **Buffer Management**: Hardware buffers allocated and shared between layers
|
||||
3. **Event Processing**: Touch events processed and forwarded to Kernel
|
||||
4. **Rendering Loop**: Continuous rendering with frame synchronization
|
||||
5. **UI Updates**: RmlUi engine updates UI based on events and data
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
User Touch Events → IMosisService.onTouch* → Kernel.process → RmlUi UI Updates
|
||||
Service Initialized → IMosisListener.onServiceInitialized → Rendering Setup
|
||||
Buffer Available → IMosisListener.onBufferAvailable → Texture Creation
|
||||
Frame Available → IMosisListener.onFrameAvailable → Frame Rendering
|
||||
```
|
||||
|
||||
## Key Technologies
|
||||
- **Android NDK**: Native development with C++23
|
||||
- **CMake**: Build system for native libraries
|
||||
- **RmlUi**: UI rendering engine with HTML/CSS-like markup
|
||||
- **OpenGL ES 2.0**: Graphics rendering
|
||||
- **GLAD**: OpenGL loader library
|
||||
- **AIDL**: Inter-process communication
|
||||
- **Binder**: Android's IPC mechanism
|
||||
|
||||
## File Structure
|
||||
- `mosis-service.cpp`: Main service implementation
|
||||
- `mosis-test.cpp`: Test/rendering implementation
|
||||
- `kernel.cpp`: Core rendering engine and event processing
|
||||
- `assets_manager.cpp`: Asset loading utilities
|
||||
- `egl_context.cpp`: EGL context management
|
||||
- `render_target.cpp`: Framebuffer handling
|
||||
- `logger.cpp`: Cross-platform logging
|
||||
|
||||
## Code Standards
|
||||
- Modern C++23 with smart pointers and RAII
|
||||
- Thread-safe operations using std::mutex
|
||||
- Resource management with proper cleanup
|
||||
- Use of std::span, std::format for modern features
|
||||
- Cross-platform compatibility through Android NDK
|
||||
758
CLAUDE.md
758
CLAUDE.md
@@ -1,758 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Mosis is a **virtual smartphone OS** for VR games and applications. It provides a phone-like device that users can interact with inside VR environments, with real smartphone functionality.
|
||||
|
||||
### Project Components
|
||||
|
||||
| Component | Location | Purpose |
|
||||
|-----------|----------|---------|
|
||||
| Android Service | `src/main/` | Native service running RmlUi renderer |
|
||||
| Desktop Designer | `designer/` | UI development with hot-reload |
|
||||
| Designer Tests | `designer-test/` | Automated UI testing framework |
|
||||
| UI Assets | `src/main/assets/` | Shared RML/RCSS/Lua assets |
|
||||
|
||||
## Build Commands
|
||||
|
||||
### Android (Gradle)
|
||||
|
||||
```bash
|
||||
# Build entire project
|
||||
./gradlew build
|
||||
|
||||
# Build debug APK
|
||||
./gradlew assembleDebug
|
||||
|
||||
# Build release APK
|
||||
./gradlew assembleRelease
|
||||
|
||||
# Clean build outputs
|
||||
./gradlew clean
|
||||
|
||||
# Build with verbose output
|
||||
./gradlew build --info --stacktrace
|
||||
|
||||
# Run lint checks
|
||||
./gradlew lint
|
||||
|
||||
# Run unit tests
|
||||
./gradlew test
|
||||
|
||||
# Run connected device tests
|
||||
./gradlew connectedAndroidTest
|
||||
```
|
||||
|
||||
### Desktop Designer (CMake)
|
||||
|
||||
```bash
|
||||
# Configure (from designer/ folder)
|
||||
cmake -B build -DCMAKE_TOOLCHAIN_FILE=%VCPKG_ROOT%/scripts/buildsystems/vcpkg.cmake
|
||||
|
||||
# Build
|
||||
cmake --build build --config Debug
|
||||
|
||||
# Run with hot-reload
|
||||
./build/Debug/mosis-designer.exe ../src/main/assets/apps/home/home.rml
|
||||
|
||||
# Run with logging and hierarchy dump
|
||||
./build/Debug/mosis-designer.exe ../src/main/assets/apps/home/home.rml --log output.log --hierarchy hierarchy.json
|
||||
```
|
||||
|
||||
### Designer Tests (CMake)
|
||||
|
||||
```bash
|
||||
# Configure (from designer-test/ folder)
|
||||
cmake -B build -DCMAKE_TOOLCHAIN_FILE=%VCPKG_ROOT%/scripts/buildsystems/vcpkg.cmake
|
||||
|
||||
# Build
|
||||
cmake --build build --config Debug
|
||||
|
||||
# Run tests
|
||||
./build/Debug/designer-test.exe
|
||||
```
|
||||
|
||||
## Environment Requirements
|
||||
|
||||
Required environment variables:
|
||||
- `ANDROID_HOME` - Android SDK path
|
||||
- `ANDROID_NDK_HOME` - Android NDK path (version 29.0.14206865)
|
||||
- `VCPKG_ROOT` - vcpkg package manager root
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
MosisService is an Android application combining Kotlin UI with native C++ libraries for UI rendering via Android's Binder IPC system.
|
||||
|
||||
### Two Native Libraries
|
||||
|
||||
**mosis-service** (`libmosis-service.so`):
|
||||
- Main Android Binder service implementation
|
||||
- Implements `IMosisService.aidl` interface for touch events and initialization
|
||||
- Contains the Kernel rendering engine with RmlUi integration
|
||||
- Links against RmlUi for HTML/CSS-like UI rendering
|
||||
|
||||
**mosis-test** (`libmosis-test.so`):
|
||||
- Test/rendering client implementation
|
||||
- Implements `IMosisListener.aidl` for receiving callbacks
|
||||
- OpenGL ES 2.0 rendering pipeline using GLAD
|
||||
|
||||
### IPC Flow
|
||||
|
||||
```
|
||||
Kotlin NativeService → JNI → mosis-service (IMosisService)
|
||||
↓
|
||||
IMosisListener callbacks
|
||||
↓
|
||||
mosis-test (rendering client)
|
||||
```
|
||||
|
||||
### Key Interfaces (AIDL)
|
||||
|
||||
`IMosisService`: `initOS()`, `onTouchDown()`, `onTouchMove()`, `onTouchUp()`
|
||||
|
||||
`IMosisListener` (oneway/async): `onServiceInitialized()`, `onBufferAvailable()`, `onFrameAvailable()`
|
||||
|
||||
### Native Code Structure (src/main/cpp/)
|
||||
|
||||
- `kernel.cpp` - Core rendering engine, RmlUi integration, event processing
|
||||
- `mosis-service.cpp` - Binder service implementation, JNI entry points
|
||||
- `mosis-test.cpp` - Test client implementation
|
||||
- `egl_context.cpp` - OpenGL ES context management
|
||||
- `render_target.cpp` - Framebuffer and buffer management
|
||||
- `RmlUi_Renderer_GL3.cpp` - RmlUi OpenGL renderer backend
|
||||
- `assets_manager.cpp` - Android AssetManager integration
|
||||
|
||||
## Code Style
|
||||
|
||||
- C++23 standard with modern features (std::span, std::format)
|
||||
- PascalCase for classes/functions, camelCase for variables
|
||||
- RAII principles with smart pointers
|
||||
- Kotlin code follows Android conventions
|
||||
|
||||
## Dependencies
|
||||
|
||||
- vcpkg manages native dependencies (RmlUi, GLFW, Freetype, Lua, libpng, nlohmann-json)
|
||||
- CMake build system with vcpkg toolchain integration
|
||||
- Android target architecture: arm64-v8a only
|
||||
- Desktop target: Windows x64 (MSVC)
|
||||
|
||||
## Desktop Designer
|
||||
|
||||
The desktop designer (`designer/`) provides rapid UI development with:
|
||||
|
||||
- **Hot-reload**: Automatically reloads when RML/RCSS/Lua files change
|
||||
- **UI Hierarchy Dumping**: Exports element tree to JSON for inspection
|
||||
- **Screenshot Capture**: PNG export via F12 key
|
||||
- **Logging**: Detailed output for debugging navigation and events
|
||||
- **Action Recording**: Record mouse/keyboard interactions to JSON
|
||||
- **Action Playback**: Replay recorded interactions with timing
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `designer/src/main.cpp` | Main entry point, GLFW window, event loop |
|
||||
| `designer/src/desktop_kernel.cpp` | RmlUi context management, rendering |
|
||||
| `designer/src/testing/ui_inspector.cpp` | UI hierarchy JSON export |
|
||||
| `designer/src/testing/visual_capture.cpp` | PNG screenshot capture and comparison |
|
||||
| `designer/src/testing/action_recorder.cpp` | Record user interactions to JSON |
|
||||
| `designer/src/testing/action_player.cpp` | Playback recorded actions |
|
||||
| `designer/src/backend/RmlUi_Backend_GLFW_GL3.cpp` | GLFW backend with input hooks |
|
||||
|
||||
### Command Line Options
|
||||
|
||||
```
|
||||
--log <path> Write logs to file
|
||||
--hierarchy <path> Dump UI hierarchy JSON each frame
|
||||
--dump Single-shot dump mode (screenshot + hierarchy)
|
||||
--record <path> Enable recording mode (F5 to start/stop)
|
||||
--playback <path> Play back recorded actions from JSON
|
||||
```
|
||||
|
||||
### Keyboard Controls
|
||||
|
||||
| Key | Function |
|
||||
|-----|----------|
|
||||
| F5 | Start/stop recording (when --record is enabled) |
|
||||
| F6 | Pause/resume playback (when --playback is enabled) |
|
||||
| F12 | Take screenshot |
|
||||
|
||||
## Automated Testing Framework
|
||||
|
||||
The designer-test (`designer-test/`) provides automated UI testing:
|
||||
|
||||
### Test Architecture
|
||||
|
||||
1. **WindowController**: Finds designer window, sends mouse/keyboard events via Windows SendInput API
|
||||
2. **HierarchyReader**: Parses UI hierarchy JSON to find elements by ID/class (with retry logic and exponential backoff)
|
||||
3. **LogParser**: Monitors log file for navigation events
|
||||
4. **TestRunner**: Orchestrates test execution, reports results
|
||||
5. **UIInspector**: Dumps UI hierarchy with atomic writes (temp file + rename pattern)
|
||||
|
||||
### Key Implementation Details
|
||||
|
||||
- **Path Normalization**: RmlUi uses `|` instead of `:` in Windows paths (e.g., `D|\Dev\...`). The UIInspector normalizes paths for correct document matching.
|
||||
- **Atomic File Writes**: Hierarchy files are written to `.tmp` then renamed to prevent partial reads.
|
||||
- **Retry with Backoff**: HierarchyReader retries up to 10 times with exponential backoff (30ms base) and validates JSON completeness.
|
||||
- **Dynamic Back Button**: `GoHome()` finds back buttons from hierarchy by class (`app-bar-nav` or `browser-nav-btn`) instead of fixed coordinates.
|
||||
|
||||
### Writing Tests
|
||||
|
||||
```cpp
|
||||
// Find element by ID and click it
|
||||
bool ClickById(TestContext& ctx, const std::string& id) {
|
||||
ctx.hierarchy.Reload();
|
||||
auto element = ctx.hierarchy.FindById(id);
|
||||
if (!element) return false;
|
||||
|
||||
int x = element->bounds.centerX();
|
||||
int y = element->bounds.centerY();
|
||||
ScaleToPhysical(ctx, x, y); // Convert logical to physical coords
|
||||
ctx.window.SendClick(x, y);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Test example
|
||||
bool TestNavigateToDialer(TestContext& ctx) {
|
||||
GoHome(ctx);
|
||||
ctx.log.Clear();
|
||||
|
||||
if (!ClickById(ctx, "dock-phone")) return false;
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
|
||||
ctx.log.Reload();
|
||||
return ctx.log.Contains("Loaded screen: apps/dialer/dialer.rml");
|
||||
}
|
||||
```
|
||||
|
||||
### Test Output
|
||||
|
||||
Tests produce JSON results at `test_results.json`:
|
||||
```json
|
||||
{
|
||||
"name": "Mosis Designer UI Tests",
|
||||
"summary": {"passed": 5, "failed": 0, "total": 5},
|
||||
"tests": [
|
||||
{"name": "Navigate to Dialer", "status": "passed", "duration_ms": 3500}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## UI Assets Structure
|
||||
|
||||
All UI assets are in `src/main/assets/`:
|
||||
|
||||
```
|
||||
assets/
|
||||
├── apps/ # System apps (RML documents)
|
||||
│ ├── home/home.rml # Home screen launcher
|
||||
│ ├── dialer/dialer.rml # Phone dialer
|
||||
│ ├── messages/ # Messages app
|
||||
│ ├── contacts/ # Contacts app
|
||||
│ ├── settings/ # Settings app
|
||||
│ └── browser/ # Web browser
|
||||
├── ui/ # Shared stylesheets
|
||||
│ ├── html.rcss # Base HTML element styles
|
||||
│ ├── theme.rcss # Design tokens (colors, typography)
|
||||
│ └── components.rcss # Reusable UI components
|
||||
├── scripts/ # Lua scripts
|
||||
│ └── navigation.lua # Navigation system
|
||||
├── icons/ # TGA icon files (24x24, 32x32)
|
||||
└── fonts/ # TTF fonts (LatoLatin, Roboto)
|
||||
```
|
||||
|
||||
### Navigation System
|
||||
|
||||
Navigation is handled by `scripts/navigation.lua`:
|
||||
|
||||
```lua
|
||||
-- Navigate to a screen
|
||||
navigateTo('dialer') -- Push to history, animate forward
|
||||
|
||||
-- Go back
|
||||
goBack() -- Pop from history, animate back
|
||||
|
||||
-- Go home
|
||||
goHome() -- Clear history, return to home
|
||||
```
|
||||
|
||||
### Element IDs for Testing
|
||||
|
||||
Key elements have IDs for automated testing:
|
||||
|
||||
| ID | Location | Purpose |
|
||||
|----|----------|---------|
|
||||
| `dock-phone` | home.rml | Phone dock icon |
|
||||
| `dock-messages` | home.rml | Messages dock icon |
|
||||
| `dock-contacts` | home.rml | Contacts dock icon |
|
||||
| `dock-browser` | home.rml | Browser dock icon |
|
||||
| `app-settings` | home.rml | Settings app icon |
|
||||
|
||||
### CSS Classes for Navigation
|
||||
|
||||
Back buttons use `app-bar-nav` class for automated GoHome:
|
||||
```html
|
||||
<div class="app-bar-nav btn-icon" onclick="goBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
```
|
||||
|
||||
Browser uses `browser-nav-btn` class for its toolbar back button:
|
||||
```html
|
||||
<div class="app-bar-nav browser-nav-btn" onclick="goBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
```
|
||||
|
||||
The test framework's `FindBackButton()` searches for both classes to handle all screen layouts.
|
||||
|
||||
## Material Design Resources
|
||||
|
||||
Material Design icons and components are available in the MosisDesigner repository:
|
||||
|
||||
### Material Design Icons
|
||||
|
||||
**Location**: `D:\Dev\Mosis\MosisDesigner\material-design-icons`
|
||||
|
||||
A comprehensive icon library from Google with 2000+ icons across 20 categories:
|
||||
|
||||
| Category | Examples |
|
||||
|----------|----------|
|
||||
| action | home, search, settings, delete, info |
|
||||
| alert | error, warning, notification |
|
||||
| av | play, pause, volume, mic |
|
||||
| communication | phone, message, email, contacts |
|
||||
| content | add, remove, copy, paste |
|
||||
| device | battery, wifi, bluetooth, gps |
|
||||
| editor | format, text, color, brush |
|
||||
| file | folder, attachment, download, upload |
|
||||
| hardware | keyboard, mouse, phone, tablet |
|
||||
| home | lightbulb, thermostat, security |
|
||||
| image | camera, photo, filter, tune |
|
||||
| maps | location, directions, navigation |
|
||||
| navigation | arrow, chevron, menu, close |
|
||||
| notification | sync, update, event |
|
||||
| places | hotel, restaurant, airport |
|
||||
| search | search variants |
|
||||
| social | share, person, group, notifications |
|
||||
| toggle | star, checkbox, radio |
|
||||
|
||||
**Available Formats**:
|
||||
- `src/` - SVG source files organized by category
|
||||
- `png/` - PNG files at multiple DPIs (24dp, 36dp, 48dp)
|
||||
- `font/` - Icon fonts (WOFF, TTF)
|
||||
- `symbols/` - Material Symbols variable font (newer)
|
||||
- `variablefont/` - Variable font files
|
||||
|
||||
**Icon Styles**:
|
||||
- Outlined (default)
|
||||
- Filled
|
||||
- Rounded
|
||||
- Sharp
|
||||
- Two-tone (Material Icons only)
|
||||
|
||||
### Material Design Lite
|
||||
|
||||
**Location**: `D:\Dev\Mosis\MosisDesigner\material-design-lite`
|
||||
|
||||
CSS/JS component library implementing Material Design (reference implementation):
|
||||
|
||||
| Directory | Contents |
|
||||
|-----------|----------|
|
||||
| `src/` | SASS source for components |
|
||||
| `docs/` | Component documentation |
|
||||
| `templates/` | Page templates |
|
||||
|
||||
**Key Components** (for design reference):
|
||||
- Buttons (raised, flat, FAB)
|
||||
- Cards
|
||||
- Dialogs
|
||||
- Lists
|
||||
- Menus
|
||||
- Navigation drawers
|
||||
- Progress indicators
|
||||
- Sliders
|
||||
- Snackbars
|
||||
- Tables
|
||||
- Tabs
|
||||
- Text fields
|
||||
- Tooltips
|
||||
|
||||
### Using Icons in Mosis
|
||||
|
||||
1. **Find icon** at https://fonts.google.com/icons
|
||||
2. **Export SVG** from `material-design-icons/src/<category>/<name>/`
|
||||
3. **Convert to TGA** using image tool (24x24 or 32x32, RGBA)
|
||||
4. **Place in** `src/main/assets/icons/`
|
||||
5. **Reference in RML**: `<img src="../../icons/<name>.tga"/>`
|
||||
|
||||
## Android Device Testing
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
# Check connected device
|
||||
adb devices -l
|
||||
|
||||
# Verify Mosis app is installed
|
||||
adb shell pm list packages | grep mosis
|
||||
```
|
||||
|
||||
### Build and Install
|
||||
|
||||
```bash
|
||||
# Build debug APK
|
||||
./gradlew assembleDebug
|
||||
|
||||
# Install on device
|
||||
adb install -r build/outputs/apk/debug/MosisService-debug.apk
|
||||
|
||||
# Launch the app
|
||||
adb shell am start -n com.omixlab.mosis/.MainActivity
|
||||
```
|
||||
|
||||
### Run Gradle Connected Tests
|
||||
|
||||
```bash
|
||||
# Run all connected Android tests
|
||||
./gradlew connectedAndroidTest
|
||||
```
|
||||
|
||||
### Event Injection via ADB
|
||||
|
||||
Inject touch events for automated testing:
|
||||
|
||||
```bash
|
||||
# Click at normalized coordinates (0.0-1.0)
|
||||
adb shell am broadcast -a com.omixlab.mosis.INJECT_TOUCH \
|
||||
--es touch_type "click" --ef x 0.5 --ef y 0.5
|
||||
|
||||
# Touch down
|
||||
adb shell am broadcast -a com.omixlab.mosis.INJECT_TOUCH \
|
||||
--es touch_type "down" --ef x 0.2 --ef y 0.9
|
||||
|
||||
# Touch up
|
||||
adb shell am broadcast -a com.omixlab.mosis.INJECT_TOUCH \
|
||||
--es touch_type "up" --ef x 0.2 --ef y 0.9
|
||||
```
|
||||
|
||||
### Dock Element Coordinates (Normalized)
|
||||
|
||||
| Element | X | Y |
|
||||
|---------|---|---|
|
||||
| dock-phone | 0.16 | 0.97 |
|
||||
| dock-messages | 0.39 | 0.97 |
|
||||
| dock-contacts | 0.61 | 0.97 |
|
||||
| dock-browser | 0.84 | 0.97 |
|
||||
| back-button | 0.10 | 0.05 |
|
||||
|
||||
### Full Navigation Test Sequence
|
||||
|
||||
```bash
|
||||
# Clear logs and run navigation test sequence
|
||||
adb logcat -c
|
||||
|
||||
# Click Phone dock icon
|
||||
adb shell am broadcast -a com.omixlab.mosis.INJECT_TOUCH \
|
||||
--es touch_type "click" --ef x 0.16 --ef y 0.97
|
||||
sleep 2
|
||||
|
||||
# Click back to return home
|
||||
adb shell am broadcast -a com.omixlab.mosis.INJECT_TOUCH \
|
||||
--es touch_type "click" --ef x 0.1 --ef y 0.05
|
||||
sleep 2
|
||||
|
||||
# Click Messages dock icon
|
||||
adb shell am broadcast -a com.omixlab.mosis.INJECT_TOUCH \
|
||||
--es touch_type "click" --ef x 0.39 --ef y 0.97
|
||||
sleep 2
|
||||
|
||||
# Click back to return home
|
||||
adb shell am broadcast -a com.omixlab.mosis.INJECT_TOUCH \
|
||||
--es touch_type "click" --ef x 0.1 --ef y 0.05
|
||||
sleep 2
|
||||
|
||||
# Click Contacts dock icon
|
||||
adb shell am broadcast -a com.omixlab.mosis.INJECT_TOUCH \
|
||||
--es touch_type "click" --ef x 0.61 --ef y 0.97
|
||||
sleep 2
|
||||
|
||||
# Click back to return home
|
||||
adb shell am broadcast -a com.omixlab.mosis.INJECT_TOUCH \
|
||||
--es touch_type "click" --ef x 0.1 --ef y 0.05
|
||||
sleep 2
|
||||
|
||||
# Click Browser dock icon
|
||||
adb shell am broadcast -a com.omixlab.mosis.INJECT_TOUCH \
|
||||
--es touch_type "click" --ef x 0.84 --ef y 0.97
|
||||
```
|
||||
|
||||
### Reading Logs
|
||||
|
||||
```bash
|
||||
# Filter for Mosis logs
|
||||
adb logcat -s MosisTest ServiceTester RMLUI
|
||||
|
||||
# Filter for navigation events
|
||||
adb logcat -d | grep -iE "navigat|loaded|goBack|rml"
|
||||
|
||||
# Save to file
|
||||
adb logcat -s MosisTest > mosis-log.txt
|
||||
|
||||
# Clear logs
|
||||
adb logcat -c
|
||||
```
|
||||
|
||||
### Expected Log Output
|
||||
|
||||
Successful navigation shows these log patterns:
|
||||
```
|
||||
RMLUI: navigateTo called with: dialer
|
||||
Loading screen: apps/dialer/dialer.rml
|
||||
RMLUI: Navigated to: dialer (history depth: 1)
|
||||
|
||||
RMLUI: goBack called (history depth: 1)
|
||||
Loading screen: apps/home/home.rml
|
||||
RMLUI: Back to: home
|
||||
```
|
||||
|
||||
## Documentation Guidelines
|
||||
|
||||
**IMPORTANT**: Always document progress and new commands to avoid rediscovery.
|
||||
|
||||
### Where to Put Documentation
|
||||
|
||||
| Content Type | Location |
|
||||
|--------------|----------|
|
||||
| General concepts, architecture | `MosisService/CLAUDE.md` (this file) |
|
||||
| Unreal plugin docs | `MosisUnreal/Plugins/MosisSDK/README.md` |
|
||||
| Unity package docs | `MosisVR/Packages/com.omarator.mosissdk/README.md` |
|
||||
| Project-specific build commands | Each project's own docs |
|
||||
|
||||
**DO NOT** put documentation in the root `D:\Dev\Mosis\` directory - it is not versioned.
|
||||
|
||||
### What to Document
|
||||
|
||||
1. **Build commands** - Every new build command discovered or created
|
||||
2. **Environment setup** - Required environment variables, SDK versions
|
||||
3. **Architecture decisions** - Why something was done a certain way
|
||||
4. **Issues and solutions** - Problems encountered and how they were fixed
|
||||
5. **File locations** - Where important files are and what they do
|
||||
|
||||
### When to Document
|
||||
|
||||
- After completing a milestone or feature
|
||||
- When discovering new build commands
|
||||
- When fixing a non-obvious issue
|
||||
- When adding new dependencies or requirements
|
||||
|
||||
## Game Engine Integrations
|
||||
|
||||
MosisService provides a virtual phone that game engines can display and interact with.
|
||||
|
||||
### Integration Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ MosisService │
|
||||
│ (OpenGL ES rendering → AHardwareBuffer) │
|
||||
└───────────────────────────┬─────────────────────────────────────┘
|
||||
│ Binder IPC + Shared Memory
|
||||
┌─────────────────┴─────────────────┐
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ MosisUnreal │ │ MosisVR │
|
||||
│ (UE5.5 Plugin) │ │ (Unity Package) │
|
||||
│ Vulkan Import │ │ Vulkan/OpenGL │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
### MosisUnreal (Unreal Engine 5.5)
|
||||
|
||||
**Location**: `D:\Dev\Mosis\MosisUnreal\Plugins\MosisSDK\`
|
||||
|
||||
#### Build Commands
|
||||
|
||||
```batch
|
||||
:: Windows Editor Build
|
||||
"D:\Epic\UE_5.5\Engine\Build\BatchFiles\Build.bat" ^
|
||||
MosisUnrealEditor Win64 Development ^
|
||||
-Project="D:\Dev\Mosis\MosisUnreal\MosisUnreal.uproject"
|
||||
|
||||
:: Android APK Build
|
||||
"D:\Epic\UE_5.5\Engine\Build\BatchFiles\RunUAT.bat" ^
|
||||
BuildCookRun ^
|
||||
-project="D:\Dev\Mosis\MosisUnreal\MosisUnreal.uproject" ^
|
||||
-platform=Android -clientconfig=Development ^
|
||||
-build -cook -stage -pak -package -noP4
|
||||
|
||||
:: Clean Build (delete these folders first)
|
||||
rmdir /s /q "Intermediate\Build"
|
||||
rmdir /s /q "Binaries"
|
||||
```
|
||||
|
||||
#### Output Files
|
||||
|
||||
| Build | Output |
|
||||
|-------|--------|
|
||||
| Windows Editor | `Plugins/MosisSDK/Binaries/Win64/UnrealEditor-MosisSDK.dll` |
|
||||
| Android | `Binaries/Android/MosisUnreal-arm64.apk` |
|
||||
|
||||
#### Requirements
|
||||
|
||||
- Android SDK Platform 36 (for AIDL binder headers)
|
||||
- Android Build Tools 36.1.0 (for AIDL compiler)
|
||||
- `ANDROID_HOME` environment variable set
|
||||
|
||||
### MosisVR (Unity 6000.3.2f1)
|
||||
|
||||
**Location**: `D:\Dev\Mosis\MosisVR\Packages\com.omarator.mosissdk\`
|
||||
|
||||
#### Direct APK Build (Recommended)
|
||||
|
||||
```batch
|
||||
"C:\Program Files\Unity\Hub\Editor\6000.3.2f1\Editor\Unity.exe" ^
|
||||
-batchmode -quit -nographics ^
|
||||
-projectPath "D:\Dev\Mosis\MosisVR" ^
|
||||
-executeMethod BuildScript.BuildAndroidDirectCI ^
|
||||
-outputPath "D:\Dev\Mosis\Builds\Unity\Android\MosisVR.apk"
|
||||
```
|
||||
|
||||
#### Export + Gradle Build
|
||||
|
||||
For more control, export a Gradle project then build separately:
|
||||
|
||||
```batch
|
||||
:: Step 1: Export from Unity
|
||||
"C:\Program Files\Unity\Hub\Editor\6000.3.2f1\Editor\Unity.exe" ^
|
||||
-batchmode -quit -nographics ^
|
||||
-projectPath "D:\Dev\Mosis\MosisVR" ^
|
||||
-executeMethod BuildScript.BuildAndroidCI ^
|
||||
-export true ^
|
||||
-outputPath "D:\Dev\Mosis\Builds\Unity\Android\MosisVR"
|
||||
|
||||
:: Step 2: Build with Gradle
|
||||
cd D:\Dev\Mosis\Builds\Unity\Android\MosisVR
|
||||
gradle assembleRelease
|
||||
:: APK at: launcher\build\outputs\apk\release\launcher-release.apk
|
||||
```
|
||||
|
||||
#### Unity Editor Manual Build
|
||||
|
||||
1. File > Build Settings > Android
|
||||
2. Player Settings: IL2CPP, ARM64, Vulkan + OpenGLES3
|
||||
3. For direct APK: Uncheck "Export Project", click Build
|
||||
4. For export: Check "Export Project", click Export
|
||||
|
||||
#### Native Plugin Build (Manual)
|
||||
|
||||
The native plugin builds automatically via CMake during Unity's build. To rebuild manually:
|
||||
|
||||
```batch
|
||||
cd Packages/com.omarator.mosissdk/Plugins/Android/cpp
|
||||
cmake -B build -DCMAKE_TOOLCHAIN_FILE=%ANDROID_NDK_HOME%/build/cmake/android.toolchain.cmake ^
|
||||
-DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-29
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
### Device Testing (Both Engines)
|
||||
|
||||
```bash
|
||||
# Install MosisService first
|
||||
adb install -r MosisService-debug.apk
|
||||
|
||||
# Install game client
|
||||
adb install -r MosisUnreal-arm64.apk # or MosisVR.apk
|
||||
|
||||
# Launch service
|
||||
adb shell am start -n com.omixlab.mosis/.MainActivity
|
||||
|
||||
# Launch client
|
||||
adb shell am start -n com.omixlab.MosisUnreal/com.epicgames.unreal.GameActivity
|
||||
# or for Unity:
|
||||
adb shell am start -n com.omixlab.mosisvr/com.unity3d.player.UnityPlayerActivity
|
||||
|
||||
# Monitor all Mosis logs
|
||||
adb logcat -s MosisSDK MosisTest RMLUI Vulkan
|
||||
```
|
||||
|
||||
## Vulkan HardwareBuffer Import
|
||||
|
||||
Both game engines use Vulkan to import AHardwareBuffer from MosisService.
|
||||
|
||||
### Required Vulkan Extensions
|
||||
|
||||
```
|
||||
VK_ANDROID_external_memory_android_hardware_buffer
|
||||
VK_KHR_external_memory
|
||||
VK_KHR_dedicated_allocation
|
||||
```
|
||||
|
||||
### Import Pattern
|
||||
|
||||
```cpp
|
||||
// 1. Query buffer properties
|
||||
VkAndroidHardwareBufferPropertiesANDROID props = {
|
||||
.sType = VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_PROPERTIES_ANDROID
|
||||
};
|
||||
VkAndroidHardwareBufferFormatPropertiesANDROID formatProps = {
|
||||
.sType = VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_FORMAT_PROPERTIES_ANDROID
|
||||
};
|
||||
props.pNext = &formatProps;
|
||||
vkGetAndroidHardwareBufferPropertiesANDROID(device, buffer, &props);
|
||||
|
||||
// 2. Create image with external memory
|
||||
VkExternalMemoryImageCreateInfo extInfo = {
|
||||
.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO,
|
||||
.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_ANDROID_HARDWARE_BUFFER_BIT_ANDROID
|
||||
};
|
||||
|
||||
AHardwareBuffer_Desc desc;
|
||||
AHardwareBuffer_describe(buffer, &desc);
|
||||
|
||||
VkImageCreateInfo imageInfo = {
|
||||
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
|
||||
.pNext = &extInfo,
|
||||
.imageType = VK_IMAGE_TYPE_2D,
|
||||
.format = formatProps.format,
|
||||
.extent = {desc.width, desc.height, 1},
|
||||
.mipLevels = 1,
|
||||
.arrayLayers = 1,
|
||||
.samples = VK_SAMPLE_COUNT_1_BIT,
|
||||
.tiling = VK_IMAGE_TILING_OPTIMAL,
|
||||
.usage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
|
||||
.sharingMode = VK_SHARING_MODE_EXCLUSIVE
|
||||
};
|
||||
vkCreateImage(device, &imageInfo, nullptr, &image);
|
||||
|
||||
// 3. Import memory from HardwareBuffer
|
||||
VkImportAndroidHardwareBufferInfoANDROID importInfo = {
|
||||
.sType = VK_STRUCTURE_TYPE_IMPORT_ANDROID_HARDWARE_BUFFER_INFO_ANDROID,
|
||||
.buffer = buffer
|
||||
};
|
||||
|
||||
VkMemoryDedicatedAllocateInfo dedicatedInfo = {
|
||||
.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO,
|
||||
.pNext = &importInfo,
|
||||
.image = image
|
||||
};
|
||||
|
||||
VkMemoryAllocateInfo allocInfo = {
|
||||
.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,
|
||||
.pNext = &dedicatedInfo,
|
||||
.allocationSize = props.allocationSize,
|
||||
.memoryTypeIndex = FindMemoryType(props.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT)
|
||||
};
|
||||
vkAllocateMemory(device, &allocInfo, nullptr, &memory);
|
||||
vkBindImageMemory(device, image, memory, 0);
|
||||
```
|
||||
|
||||
### Synchronization
|
||||
|
||||
Currently using CPU synchronization (`vkQueueWaitIdle` / `glFinish`). Future improvement: use Vulkan semaphores for GPU-GPU sync.
|
||||
|
||||
### Double Buffering
|
||||
|
||||
The imported image is copied to a local texture each frame to prevent data races with MosisService rendering.
|
||||
307
base-apps/com.mosis.browser/browser.lua
Normal file
307
base-apps/com.mosis.browser/browser.lua
Normal file
@@ -0,0 +1,307 @@
|
||||
-- browser.lua - Web browser functionality
|
||||
-- Handles URL navigation, tabs, bookmarks, and history
|
||||
|
||||
local browser_doc = nil
|
||||
local tabs = {}
|
||||
local current_tab_id = 1
|
||||
local history = {}
|
||||
local bookmarks = {}
|
||||
|
||||
-- Sample page content
|
||||
local pages = {
|
||||
["example.com"] = {
|
||||
title = "Example Domain",
|
||||
secure = true,
|
||||
content = [[
|
||||
<div class="browser-page-title">Example Domain</div>
|
||||
<div class="browser-page-text">
|
||||
This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.
|
||||
</div>
|
||||
<div class="browser-page-text">
|
||||
<span class="browser-page-link" onclick="navigateToUrl('iana.org')">More information...</span>
|
||||
</div>
|
||||
]]
|
||||
},
|
||||
["google.com"] = {
|
||||
title = "Google",
|
||||
secure = true,
|
||||
content = [[
|
||||
<div style="text-align: center; padding: 60px 20px;">
|
||||
<div style="font-size: 48px; font-weight: 500; color: #4285F4;">G</div>
|
||||
<div style="font-size: 48px; font-weight: 500; color: #EA4335;">o</div>
|
||||
<div style="font-size: 48px; font-weight: 500; color: #FBBC05;">o</div>
|
||||
<div style="font-size: 48px; font-weight: 500; color: #4285F4;">g</div>
|
||||
<div style="font-size: 48px; font-weight: 500; color: #34A853;">l</div>
|
||||
<div style="font-size: 48px; font-weight: 500; color: #EA4335;">e</div>
|
||||
<div style="margin-top: 32px;">
|
||||
<input type="text" style="width: 80%; padding: 12px 20px; font-size: 16px; border-radius: 24px; background-color: #f1f3f4; border: none;" placeholder="Search Google"/>
|
||||
</div>
|
||||
</div>
|
||||
]]
|
||||
},
|
||||
["mosis.app"] = {
|
||||
title = "Mosis - Virtual Smartphone for VR",
|
||||
secure = true,
|
||||
content = [[
|
||||
<div class="browser-page-title">Welcome to Mosis</div>
|
||||
<div class="browser-page-text">
|
||||
Mosis is a virtual smartphone OS for VR games and applications. Experience a phone-like device inside your virtual reality environment.
|
||||
</div>
|
||||
<div style="margin-top: 24px;">
|
||||
<div class="browser-search-item" onclick="navigateToUrl('mosis.app/apps')">
|
||||
<div class="browser-search-title">Browse Apps</div>
|
||||
<div class="browser-search-desc">Discover apps for your virtual phone</div>
|
||||
</div>
|
||||
<div class="browser-search-item" onclick="navigateToUrl('mosis.app/developers')">
|
||||
<div class="browser-search-title">For Developers</div>
|
||||
<div class="browser-search-desc">Build apps for the Mosis platform</div>
|
||||
</div>
|
||||
</div>
|
||||
]]
|
||||
},
|
||||
["default"] = {
|
||||
title = "Page Not Found",
|
||||
secure = false,
|
||||
content = [[
|
||||
<div style="text-align: center; padding: 60px 20px;">
|
||||
<div style="font-size: 64px; color: #888888;">:(</div>
|
||||
<div style="font-size: 24px; color: #333333; margin-top: 24px;">Page Not Found</div>
|
||||
<div style="font-size: 16px; color: #666666; margin-top: 12px;">The requested page could not be loaded.</div>
|
||||
</div>
|
||||
]]
|
||||
}
|
||||
}
|
||||
|
||||
-- Initialize tabs
|
||||
local function initTabs()
|
||||
tabs = {
|
||||
{id = 1, url = "example.com", title = "Example Domain"}
|
||||
}
|
||||
current_tab_id = 1
|
||||
end
|
||||
|
||||
-- Initialize browser
|
||||
function initBrowser(doc)
|
||||
print("[Browser] Initializing...")
|
||||
browser_doc = doc
|
||||
initTabs()
|
||||
loadPage(tabs[1].url)
|
||||
end
|
||||
|
||||
-- Get current tab
|
||||
local function getCurrentTab()
|
||||
for _, tab in ipairs(tabs) do
|
||||
if tab.id == current_tab_id then
|
||||
return tab
|
||||
end
|
||||
end
|
||||
return tabs[1]
|
||||
end
|
||||
|
||||
-- Load a page
|
||||
function loadPage(url)
|
||||
if not browser_doc then return end
|
||||
|
||||
print("[Browser] Loading: " .. url)
|
||||
|
||||
-- Clean URL
|
||||
url = url:gsub("^https?://", ""):gsub("^www%.", ""):gsub("/$", "")
|
||||
|
||||
-- Update current tab
|
||||
local tab = getCurrentTab()
|
||||
if tab then
|
||||
tab.url = url
|
||||
end
|
||||
|
||||
-- Add to history
|
||||
table.insert(history, 1, {url = url, time = "Just now"})
|
||||
|
||||
-- Get page data
|
||||
local page = pages[url] or pages["default"]
|
||||
if tab then
|
||||
tab.title = page.title
|
||||
end
|
||||
|
||||
-- Update URL bar
|
||||
local url_input = browser_doc:GetElementById("url-input")
|
||||
if url_input then
|
||||
url_input.value = url
|
||||
end
|
||||
|
||||
-- Update secure icon
|
||||
local secure_icon = browser_doc:GetElementById("secure-icon")
|
||||
if secure_icon then
|
||||
if page.secure then
|
||||
secure_icon.inner_rml = "S"
|
||||
secure_icon.style.color = "#4CAF50"
|
||||
else
|
||||
secure_icon.inner_rml = "!"
|
||||
secure_icon.style.color = "#F44336"
|
||||
end
|
||||
end
|
||||
|
||||
-- Update page title
|
||||
local title = browser_doc:GetElementById("page-title")
|
||||
if title then
|
||||
title.inner_rml = page.title
|
||||
end
|
||||
|
||||
-- Update content
|
||||
local content = browser_doc:GetElementById("browser-content")
|
||||
if content then
|
||||
content.inner_rml = [[<div class="browser-page">]] .. page.content .. [[</div>]]
|
||||
end
|
||||
|
||||
-- Update tab count
|
||||
updateTabCount()
|
||||
end
|
||||
|
||||
-- Navigate to URL
|
||||
function navigateToUrl(url)
|
||||
loadPage(url)
|
||||
end
|
||||
|
||||
-- Handle URL input
|
||||
function onUrlSubmit()
|
||||
if not browser_doc then return end
|
||||
|
||||
local input = browser_doc:GetElementById("url-input")
|
||||
if input then
|
||||
local url = input.value or ""
|
||||
if url ~= "" then
|
||||
loadPage(url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Go back in history
|
||||
function browserBack()
|
||||
if #history > 1 then
|
||||
table.remove(history, 1) -- Remove current page
|
||||
local prev = history[1]
|
||||
if prev then
|
||||
loadPage(prev.url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Go forward (simplified - just reload)
|
||||
function browserForward()
|
||||
if showToast then
|
||||
showToast("No forward history")
|
||||
end
|
||||
end
|
||||
|
||||
-- Refresh page
|
||||
function browserRefresh()
|
||||
local tab = getCurrentTab()
|
||||
if tab then
|
||||
loadPage(tab.url)
|
||||
if showToast then
|
||||
showToast("Page refreshed")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Update tab count display
|
||||
function updateTabCount()
|
||||
if not browser_doc then return end
|
||||
|
||||
local count = browser_doc:GetElementById("tab-count")
|
||||
if count then
|
||||
count.inner_rml = tostring(#tabs)
|
||||
end
|
||||
end
|
||||
|
||||
-- Open new tab
|
||||
function newTab()
|
||||
local new_id = #tabs + 1
|
||||
table.insert(tabs, {
|
||||
id = new_id,
|
||||
url = "mosis.app",
|
||||
title = "New Tab"
|
||||
})
|
||||
current_tab_id = new_id
|
||||
loadPage("mosis.app")
|
||||
updateTabCount()
|
||||
print("[Browser] New tab opened: " .. new_id)
|
||||
end
|
||||
|
||||
-- Show tabs view
|
||||
function showTabs()
|
||||
print("[Browser] Show tabs")
|
||||
if showToast then
|
||||
showToast(#tabs .. " tab(s) open")
|
||||
end
|
||||
end
|
||||
|
||||
-- Close current tab
|
||||
function closeTab()
|
||||
if #tabs > 1 then
|
||||
for i, tab in ipairs(tabs) do
|
||||
if tab.id == current_tab_id then
|
||||
table.remove(tabs, i)
|
||||
break
|
||||
end
|
||||
end
|
||||
current_tab_id = tabs[1].id
|
||||
loadPage(tabs[1].url)
|
||||
updateTabCount()
|
||||
else
|
||||
if showToast then
|
||||
showToast("Cannot close last tab")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Add to bookmarks
|
||||
function addBookmark()
|
||||
local tab = getCurrentTab()
|
||||
if tab then
|
||||
table.insert(bookmarks, {
|
||||
url = tab.url,
|
||||
title = tab.title
|
||||
})
|
||||
if showToast then
|
||||
showToast("Bookmark added")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Show bookmarks
|
||||
function showBookmarks()
|
||||
print("[Browser] Show bookmarks")
|
||||
if showToast then
|
||||
showToast(#bookmarks .. " bookmark(s)")
|
||||
end
|
||||
end
|
||||
|
||||
-- Show history
|
||||
function showHistory()
|
||||
print("[Browser] Show history")
|
||||
if showToast then
|
||||
showToast(#history .. " items in history")
|
||||
end
|
||||
end
|
||||
|
||||
-- Share page
|
||||
function sharePage()
|
||||
local tab = getCurrentTab()
|
||||
if tab then
|
||||
print("[Browser] Share: " .. tab.url)
|
||||
if showToast then
|
||||
showToast("Share: " .. tab.url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Show menu
|
||||
function showBrowserMenu()
|
||||
print("[Browser] Show menu")
|
||||
-- TODO: Show dropdown menu
|
||||
end
|
||||
|
||||
-- Go to home
|
||||
function browserHome()
|
||||
loadPage("mosis.app")
|
||||
end
|
||||
246
base-apps/com.mosis.browser/browser.rml
Normal file
246
base-apps/com.mosis.browser/browser.rml
Normal file
@@ -0,0 +1,246 @@
|
||||
<rml>
|
||||
<head>
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
<script src="browser.lua"></script>
|
||||
<title>Browser</title>
|
||||
<style>
|
||||
.browser-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background-color: #1E1E1E;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.browser-nav-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.browser-nav-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.browser-nav-btn:active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.browser-nav-btn img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.browser-nav-btn.disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.browser-url-bar {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
background-color: #2D2D2D;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browser-url-bar:hover {
|
||||
background-color: #3D3D3D;
|
||||
}
|
||||
|
||||
.browser-secure-icon {
|
||||
font-size: 16px;
|
||||
color: #4CAF50;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.browser-url {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
color: #FFFFFF;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.browser-content {
|
||||
flex: 1;
|
||||
background-color: #FFFFFF;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.browser-page {
|
||||
padding: 16px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.browser-page-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #1a0dab;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.browser-page-text {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #333333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.browser-page-link {
|
||||
color: #1a0dab;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browser-page-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.browser-search-item {
|
||||
margin-bottom: 20px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.browser-search-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.browser-search-title {
|
||||
font-size: 18px;
|
||||
color: #1a0dab;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.browser-search-url {
|
||||
font-size: 14px;
|
||||
color: #006621;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.browser-search-desc {
|
||||
font-size: 16px;
|
||||
color: #545454;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.browser-bottom-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background-color: #1E1E1E;
|
||||
border-top: 1px solid #333333;
|
||||
}
|
||||
|
||||
.browser-tab-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
.browser-tab-btn:hover {
|
||||
color: #FFFFFF;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.browser-tab-btn img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.browser-tab-btn span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.browser-tabs-indicator {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #B3B3B3;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #B3B3B3;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="app-screen" onload="initLayout(document); initBrowser(document)" data-model="browser">
|
||||
<!-- System Status Bar -->
|
||||
<div class="system-status-bar">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browser Toolbar -->
|
||||
<div class="browser-toolbar">
|
||||
<div class="browser-nav-btn" onclick="browserBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<div class="browser-nav-btn" onclick="browserForward()">
|
||||
<img src="../../icons/forward.tga"/>
|
||||
</div>
|
||||
<div class="browser-url-bar">
|
||||
<span class="browser-secure-icon" id="secure-icon">S</span>
|
||||
<input class="browser-url" type="text" value="example.com" id="url-input" onchange="onUrlSubmit()"/>
|
||||
</div>
|
||||
<div class="browser-nav-btn" onclick="browserRefresh()">
|
||||
<img src="../../icons/refresh.tga"/>
|
||||
</div>
|
||||
<div class="browser-nav-btn" onclick="showBrowserMenu()">
|
||||
<img src="../../icons/more.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browser Content -->
|
||||
<div class="browser-content" id="browser-content">
|
||||
<div class="browser-page">
|
||||
<div class="browser-page-title" id="page-title">Example Domain</div>
|
||||
<div class="browser-page-text">
|
||||
This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.
|
||||
</div>
|
||||
<div class="browser-page-text">
|
||||
<span class="browser-page-link" onclick="navigateToUrl('iana.org')">More information...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
<div class="browser-bottom-bar">
|
||||
<div class="browser-tab-btn" onclick="browserHome()">
|
||||
<img src="../../icons/home.tga"/>
|
||||
<span>Home</span>
|
||||
</div>
|
||||
<div class="browser-tab-btn" onclick="showTabs()">
|
||||
<span class="browser-tabs-indicator" id="tab-count">1</span>
|
||||
<span>Tabs</span>
|
||||
</div>
|
||||
<div class="browser-tab-btn" onclick="newTab()">
|
||||
<img src="../../icons/add.tga"/>
|
||||
<span>New Tab</span>
|
||||
</div>
|
||||
<div class="browser-tab-btn" onclick="showBrowserMenu()">
|
||||
<img src="../../icons/menu.tga"/>
|
||||
<span>Menu</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</rml>
|
||||
18
base-apps/com.mosis.browser/manifest.json
Normal file
18
base-apps/com.mosis.browser/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "com.mosis.browser",
|
||||
"name": "Browser",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "browser.rml",
|
||||
"icon": "../../icons/browser.tga",
|
||||
"description": "Web browser application",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [
|
||||
"network"
|
||||
],
|
||||
"min_api_version": 1
|
||||
}
|
||||
360
base-apps/com.mosis.camera/camera.lua
Normal file
360
base-apps/com.mosis.camera/camera.lua
Normal file
@@ -0,0 +1,360 @@
|
||||
-- camera.lua - Camera app functionality
|
||||
-- Handles capture, modes, flash, zoom, and camera switching
|
||||
|
||||
local camera_doc = nil
|
||||
local current_mode = "photo" -- photo, video, portrait, night
|
||||
local flash_mode = "auto" -- auto, on, off
|
||||
local timer_mode = "off" -- off, 3, 10
|
||||
local is_front_camera = false
|
||||
local is_recording = false
|
||||
local zoom_level = 1.0
|
||||
local photo_count = 0
|
||||
local video_duration = 0
|
||||
local video_timer_id = nil
|
||||
|
||||
-- Camera modes
|
||||
local modes = {"Night", "Portrait", "Photo", "Video", "More"}
|
||||
|
||||
-- Initialize camera
|
||||
function initCamera(doc)
|
||||
print("[Camera] Initializing...")
|
||||
camera_doc = doc
|
||||
updateModeDisplay()
|
||||
updateFlashDisplay()
|
||||
updateTimerDisplay()
|
||||
updateZoomDisplay()
|
||||
end
|
||||
|
||||
-- Update mode display
|
||||
function updateModeDisplay()
|
||||
if not camera_doc then return end
|
||||
|
||||
for _, mode in ipairs(modes) do
|
||||
local mode_el = camera_doc:GetElementById("mode-" .. mode:lower())
|
||||
if mode_el then
|
||||
if mode:lower() == current_mode then
|
||||
mode_el:SetClass("active", true)
|
||||
else
|
||||
mode_el:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Update capture button appearance for video mode
|
||||
local capture_btn = camera_doc:GetElementById("capture-button")
|
||||
if capture_btn then
|
||||
if current_mode == "video" then
|
||||
if is_recording then
|
||||
capture_btn.inner_rml = [[<div class="capture-btn-stop"></div>]]
|
||||
else
|
||||
capture_btn.inner_rml = [[<div class="capture-btn-video"></div>]]
|
||||
end
|
||||
else
|
||||
capture_btn.inner_rml = [[<div class="capture-btn-inner"></div>]]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Switch camera mode
|
||||
function switchMode(mode)
|
||||
print("[Camera] Switching to mode: " .. mode)
|
||||
current_mode = mode:lower()
|
||||
|
||||
-- Stop recording if switching from video
|
||||
if is_recording then
|
||||
stopRecording()
|
||||
end
|
||||
|
||||
updateModeDisplay()
|
||||
|
||||
if showToast then
|
||||
showToast(mode .. " mode")
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle flash
|
||||
function toggleFlash()
|
||||
if flash_mode == "auto" then
|
||||
flash_mode = "on"
|
||||
elseif flash_mode == "on" then
|
||||
flash_mode = "off"
|
||||
else
|
||||
flash_mode = "auto"
|
||||
end
|
||||
|
||||
print("[Camera] Flash: " .. flash_mode)
|
||||
updateFlashDisplay()
|
||||
end
|
||||
|
||||
-- Update flash display
|
||||
function updateFlashDisplay()
|
||||
if not camera_doc then return end
|
||||
|
||||
local indicator = camera_doc:GetElementById("flash-indicator")
|
||||
if indicator then
|
||||
local text = "Flash: "
|
||||
if flash_mode == "auto" then
|
||||
text = text .. "Auto"
|
||||
elseif flash_mode == "on" then
|
||||
text = text .. "On"
|
||||
else
|
||||
text = text .. "Off"
|
||||
end
|
||||
indicator.inner_rml = text
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle timer
|
||||
function toggleTimer()
|
||||
if timer_mode == "off" then
|
||||
timer_mode = "3"
|
||||
elseif timer_mode == "3" then
|
||||
timer_mode = "10"
|
||||
else
|
||||
timer_mode = "off"
|
||||
end
|
||||
|
||||
print("[Camera] Timer: " .. timer_mode)
|
||||
updateTimerDisplay()
|
||||
end
|
||||
|
||||
-- Update timer display
|
||||
function updateTimerDisplay()
|
||||
if not camera_doc then return end
|
||||
|
||||
local indicator = camera_doc:GetElementById("timer-indicator")
|
||||
if indicator then
|
||||
local text = "Timer: "
|
||||
if timer_mode == "off" then
|
||||
text = text .. "Off"
|
||||
else
|
||||
text = text .. timer_mode .. "s"
|
||||
end
|
||||
indicator.inner_rml = text
|
||||
end
|
||||
end
|
||||
|
||||
-- Zoom in
|
||||
function zoomIn()
|
||||
if zoom_level < 10.0 then
|
||||
zoom_level = math.min(zoom_level + 0.5, 10.0)
|
||||
updateZoomDisplay()
|
||||
end
|
||||
end
|
||||
|
||||
-- Zoom out
|
||||
function zoomOut()
|
||||
if zoom_level > 0.5 then
|
||||
zoom_level = math.max(zoom_level - 0.5, 0.5)
|
||||
updateZoomDisplay()
|
||||
end
|
||||
end
|
||||
|
||||
-- Update zoom display
|
||||
function updateZoomDisplay()
|
||||
if not camera_doc then return end
|
||||
|
||||
local indicator = camera_doc:GetElementById("zoom-level")
|
||||
if indicator then
|
||||
indicator.inner_rml = string.format("%.1fx", zoom_level)
|
||||
end
|
||||
|
||||
print("[Camera] Zoom: " .. zoom_level)
|
||||
end
|
||||
|
||||
-- Switch camera (front/back)
|
||||
function switchCamera()
|
||||
is_front_camera = not is_front_camera
|
||||
print("[Camera] Switched to " .. (is_front_camera and "front" or "back") .. " camera")
|
||||
|
||||
if showToast then
|
||||
showToast(is_front_camera and "Front camera" or "Back camera")
|
||||
end
|
||||
|
||||
-- Update viewfinder placeholder
|
||||
local placeholder = camera_doc:GetElementById("viewfinder-placeholder")
|
||||
if placeholder then
|
||||
local icon = is_front_camera and "F" or "C"
|
||||
placeholder.inner_rml = [[
|
||||
<div class="viewfinder-placeholder-icon">]] .. icon .. [[</div>
|
||||
<div>]] .. (is_front_camera and "Front Camera" or "Camera Preview") .. [[</div>
|
||||
<div style="font-size: 14px; margin-top: 8px; color: #555555;">Tap to focus</div>
|
||||
]]
|
||||
end
|
||||
end
|
||||
|
||||
-- Capture photo or start/stop video
|
||||
function capture()
|
||||
if current_mode == "video" then
|
||||
if is_recording then
|
||||
stopRecording()
|
||||
else
|
||||
startRecording()
|
||||
end
|
||||
else
|
||||
takePhoto()
|
||||
end
|
||||
end
|
||||
|
||||
-- Take a photo
|
||||
function takePhoto()
|
||||
print("[Camera] Taking photo...")
|
||||
|
||||
-- Check timer
|
||||
if timer_mode ~= "off" then
|
||||
local delay = tonumber(timer_mode) * 1000
|
||||
if showToast then
|
||||
showToast("Timer: " .. timer_mode .. " seconds")
|
||||
end
|
||||
|
||||
if setTimeout then
|
||||
setTimeout(function()
|
||||
actuallyTakePhoto()
|
||||
end, delay)
|
||||
else
|
||||
actuallyTakePhoto()
|
||||
end
|
||||
else
|
||||
actuallyTakePhoto()
|
||||
end
|
||||
end
|
||||
|
||||
-- Actually capture the photo
|
||||
function actuallyTakePhoto()
|
||||
photo_count = photo_count + 1
|
||||
print("[Camera] Photo captured! Total: " .. photo_count)
|
||||
|
||||
-- Flash effect
|
||||
if flash_mode == "on" or (flash_mode == "auto" and not is_front_camera) then
|
||||
-- Simulate flash
|
||||
end
|
||||
|
||||
-- Show capture animation/feedback
|
||||
local viewfinder = camera_doc:GetElementById("camera-viewfinder")
|
||||
if viewfinder then
|
||||
viewfinder.style["background-color"] = "#FFFFFF"
|
||||
if setTimeout then
|
||||
setTimeout(function()
|
||||
viewfinder.style["background-color"] = "#1a1a1a"
|
||||
end, 100)
|
||||
end
|
||||
end
|
||||
|
||||
if showToast then
|
||||
showToast("Photo saved")
|
||||
end
|
||||
|
||||
-- Update gallery preview
|
||||
updateGalleryPreview()
|
||||
end
|
||||
|
||||
-- Start video recording
|
||||
function startRecording()
|
||||
print("[Camera] Starting recording...")
|
||||
is_recording = true
|
||||
video_duration = 0
|
||||
|
||||
updateModeDisplay()
|
||||
|
||||
-- Start timer
|
||||
if setInterval then
|
||||
video_timer_id = setInterval(function()
|
||||
video_duration = video_duration + 1
|
||||
updateRecordingTime()
|
||||
end, 1000)
|
||||
end
|
||||
|
||||
-- Show recording indicator
|
||||
local indicator = camera_doc:GetElementById("recording-indicator")
|
||||
if indicator then
|
||||
indicator.style.display = "flex"
|
||||
end
|
||||
end
|
||||
|
||||
-- Stop video recording
|
||||
function stopRecording()
|
||||
print("[Camera] Stopping recording...")
|
||||
is_recording = false
|
||||
|
||||
-- Stop timer
|
||||
if video_timer_id and clearInterval then
|
||||
clearInterval(video_timer_id)
|
||||
video_timer_id = nil
|
||||
end
|
||||
|
||||
updateModeDisplay()
|
||||
|
||||
-- Hide recording indicator
|
||||
local indicator = camera_doc:GetElementById("recording-indicator")
|
||||
if indicator then
|
||||
indicator.style.display = "none"
|
||||
end
|
||||
|
||||
if showToast then
|
||||
local minutes = math.floor(video_duration / 60)
|
||||
local seconds = video_duration % 60
|
||||
showToast(string.format("Video saved (%02d:%02d)", minutes, seconds))
|
||||
end
|
||||
|
||||
video_duration = 0
|
||||
end
|
||||
|
||||
-- Update recording time display
|
||||
function updateRecordingTime()
|
||||
if not camera_doc then return end
|
||||
|
||||
local time_el = camera_doc:GetElementById("recording-time")
|
||||
if time_el then
|
||||
local minutes = math.floor(video_duration / 60)
|
||||
local seconds = video_duration % 60
|
||||
time_el.inner_rml = string.format("%02d:%02d", minutes, seconds)
|
||||
end
|
||||
end
|
||||
|
||||
-- Update gallery preview
|
||||
function updateGalleryPreview()
|
||||
-- In a real app, this would show the last captured photo
|
||||
local preview = camera_doc:GetElementById("gallery-preview")
|
||||
if preview then
|
||||
preview.style["background-color"] = "#4CAF50"
|
||||
end
|
||||
end
|
||||
|
||||
-- Open gallery
|
||||
function openGallery()
|
||||
print("[Camera] Opening gallery...")
|
||||
if navigateTo then
|
||||
navigateTo("gallery")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Gallery: " .. photo_count .. " photos")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Open camera settings
|
||||
function openCameraSettings()
|
||||
print("[Camera] Opening settings...")
|
||||
if showToast then
|
||||
showToast("Camera settings")
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle tap to focus
|
||||
function onViewfinderTap(x, y)
|
||||
print("[Camera] Focus at: " .. x .. ", " .. y)
|
||||
|
||||
-- Move focus indicator
|
||||
local focus = camera_doc:GetElementById("focus-indicator")
|
||||
if focus then
|
||||
focus.style.left = x .. "px"
|
||||
focus.style.top = y .. "px"
|
||||
focus.style.opacity = "1"
|
||||
|
||||
if setTimeout then
|
||||
setTimeout(function()
|
||||
focus.style.opacity = "0.8"
|
||||
end, 500)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,15 +3,14 @@
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
<script src="camera.lua"></script>
|
||||
<title>Camera</title>
|
||||
<style>
|
||||
.camera-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Top Controls */
|
||||
@@ -21,16 +20,16 @@
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
top: 36px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.camera-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 28px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -42,9 +41,14 @@
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.camera-btn:active {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.camera-btn img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Viewfinder */
|
||||
@@ -66,7 +70,6 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Placeholder for camera feed - replace with shared texture */
|
||||
.viewfinder-placeholder {
|
||||
color: #666666;
|
||||
font-size: 18px;
|
||||
@@ -136,10 +139,10 @@
|
||||
}
|
||||
|
||||
.camera-mode {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
color: #B3B3B3;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.camera-mode.active {
|
||||
@@ -156,7 +159,7 @@
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 24px 32px;
|
||||
padding: 20px 32px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
@@ -172,10 +175,15 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gallery-preview:hover {
|
||||
background-color: #444444;
|
||||
}
|
||||
|
||||
.gallery-preview img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.capture-btn {
|
||||
@@ -206,10 +214,17 @@
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.capture-btn.video .capture-btn-inner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
.capture-btn-video {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background-color: #F44336;
|
||||
}
|
||||
|
||||
.capture-btn-stop {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
background-color: #F44336;
|
||||
}
|
||||
|
||||
@@ -229,51 +244,51 @@
|
||||
}
|
||||
|
||||
.switch-camera-btn img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Settings Overlay */
|
||||
.settings-value {
|
||||
/* Indicators */
|
||||
.indicator {
|
||||
position: absolute;
|
||||
bottom: 200px;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
padding: 6px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.flash-indicator {
|
||||
top: 100px;
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.timer-indicator {
|
||||
top: 100px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.recording-indicator {
|
||||
top: 140px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
padding: 10px 18px;
|
||||
border-radius: 22px;
|
||||
font-size: 18px;
|
||||
color: #FFFFFF;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Flash modes */
|
||||
.flash-indicator {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
left: 16px;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
padding: 8px 14px;
|
||||
border-radius: 14px;
|
||||
font-size: 16px;
|
||||
color: #FFFFFF;
|
||||
.recording-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background-color: #F44336;
|
||||
}
|
||||
|
||||
/* Timer indicator */
|
||||
.timer-indicator {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
right: 16px;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
padding: 8px 14px;
|
||||
border-radius: 14px;
|
||||
font-size: 16px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Zoom slider */
|
||||
/* Zoom control */
|
||||
.zoom-control {
|
||||
position: absolute;
|
||||
bottom: 180px;
|
||||
bottom: 200px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
@@ -282,20 +297,24 @@
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
font-size: 20px;
|
||||
color: #FFFFFF;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.zoom-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.zoom-level {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
color: #FFD700;
|
||||
font-weight: 600;
|
||||
min-width: 48px;
|
||||
@@ -303,20 +322,30 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="camera-screen">
|
||||
<body class="app-screen camera-screen" onload="initLayout(document); initCamera(document)">
|
||||
<!-- System Status Bar (transparent) -->
|
||||
<div class="system-status-bar" style="background-color: transparent; position: absolute; top: 0; left: 0; right: 0; z-index: 20;">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Bar -->
|
||||
<div class="camera-top-bar">
|
||||
<div class="camera-btn" onclick="goBack()">
|
||||
<img src="../../icons/close.tga"/>
|
||||
</div>
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<div class="camera-btn">
|
||||
<div class="camera-btn" onclick="toggleFlash()">
|
||||
<img src="../../icons/flash.tga"/>
|
||||
</div>
|
||||
<div class="camera-btn">
|
||||
<div class="camera-btn" onclick="toggleTimer()">
|
||||
<img src="../../icons/timer.tga"/>
|
||||
</div>
|
||||
<div class="camera-btn">
|
||||
<div class="camera-btn" onclick="openCameraSettings()">
|
||||
<img src="../../icons/settings.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -325,11 +354,10 @@
|
||||
<!-- Viewfinder Area -->
|
||||
<div class="viewfinder-container">
|
||||
<div class="viewfinder" id="camera-viewfinder">
|
||||
<!-- This is where the shared camera texture would be rendered -->
|
||||
<div class="viewfinder-placeholder">
|
||||
<div class="viewfinder-placeholder" id="viewfinder-placeholder">
|
||||
<div class="viewfinder-placeholder-icon">C</div>
|
||||
<div>Camera Preview</div>
|
||||
<div style="font-size: 16px; margin-top: 8px; color: #555555;">
|
||||
<div style="font-size: 14px; margin-top: 8px; color: #555555;">
|
||||
Tap to focus
|
||||
</div>
|
||||
</div>
|
||||
@@ -343,41 +371,43 @@
|
||||
</div>
|
||||
|
||||
<!-- Focus Indicator -->
|
||||
<div class="focus-indicator"></div>
|
||||
<div class="focus-indicator" id="focus-indicator"></div>
|
||||
</div>
|
||||
|
||||
<!-- Flash Indicator -->
|
||||
<div class="flash-indicator">Flash: Auto</div>
|
||||
|
||||
<!-- Timer Indicator -->
|
||||
<div class="timer-indicator">Timer: Off</div>
|
||||
<!-- Indicators -->
|
||||
<div class="indicator flash-indicator" id="flash-indicator">Flash: Auto</div>
|
||||
<div class="indicator timer-indicator" id="timer-indicator">Timer: Off</div>
|
||||
<div class="indicator recording-indicator" id="recording-indicator">
|
||||
<div class="recording-dot"></div>
|
||||
<span id="recording-time">00:00</span>
|
||||
</div>
|
||||
|
||||
<!-- Zoom Control -->
|
||||
<div class="zoom-control">
|
||||
<div class="zoom-btn">-</div>
|
||||
<span class="zoom-level">1.0x</span>
|
||||
<div class="zoom-btn">+</div>
|
||||
<div class="zoom-btn" onclick="zoomOut()">-</div>
|
||||
<span class="zoom-level" id="zoom-level">1.0x</span>
|
||||
<div class="zoom-btn" onclick="zoomIn()">+</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Camera Modes -->
|
||||
<div class="camera-modes">
|
||||
<span class="camera-mode">Night</span>
|
||||
<span class="camera-mode">Portrait</span>
|
||||
<span class="camera-mode active">Photo</span>
|
||||
<span class="camera-mode">Video</span>
|
||||
<span class="camera-mode">More</span>
|
||||
<span id="mode-night" class="camera-mode" onclick="switchMode('Night')">Night</span>
|
||||
<span id="mode-portrait" class="camera-mode" onclick="switchMode('Portrait')">Portrait</span>
|
||||
<span id="mode-photo" class="camera-mode active" onclick="switchMode('Photo')">Photo</span>
|
||||
<span id="mode-video" class="camera-mode" onclick="switchMode('Video')">Video</span>
|
||||
<span id="mode-more" class="camera-mode" onclick="switchMode('More')">More</span>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
<div class="camera-bottom-bar">
|
||||
<div class="gallery-preview">
|
||||
<div class="gallery-preview" id="gallery-preview" onclick="openGallery()">
|
||||
<img src="../../icons/gallery.tga"/>
|
||||
</div>
|
||||
<div class="capture-btn" id="capture-button">
|
||||
<div class="capture-btn" id="capture-button" onclick="capture()">
|
||||
<div class="capture-btn-inner"></div>
|
||||
</div>
|
||||
<div class="switch-camera-btn">
|
||||
<div class="switch-camera-btn" onclick="switchCamera()">
|
||||
<img src="../../icons/switch-camera.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
19
base-apps/com.mosis.camera/manifest.json
Normal file
19
base-apps/com.mosis.camera/manifest.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "com.mosis.camera",
|
||||
"name": "Camera",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "camera.rml",
|
||||
"icon": "../../icons/camera.tga",
|
||||
"description": "Camera and photo capture",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [
|
||||
"camera",
|
||||
"storage"
|
||||
],
|
||||
"min_api_version": 1
|
||||
}
|
||||
374
base-apps/com.mosis.contacts/contacts.lua
Normal file
374
base-apps/com.mosis.contacts/contacts.lua
Normal file
@@ -0,0 +1,374 @@
|
||||
-- contacts.lua - Contacts management functionality
|
||||
-- Handles contact list, search, details, and actions
|
||||
|
||||
local contacts_doc = nil
|
||||
local contacts_data = {}
|
||||
local filtered_contacts = {}
|
||||
local search_query = ""
|
||||
local selected_contact = nil
|
||||
|
||||
-- Avatar colors for contacts
|
||||
local avatar_colors = {
|
||||
"#E91E63", "#9C27B0", "#673AB7", "#3F51B5", "#2196F3",
|
||||
"#03A9F4", "#00BCD4", "#009688", "#4CAF50", "#8BC34A",
|
||||
"#CDDC39", "#FFC107", "#FF9800", "#FF5722", "#795548"
|
||||
}
|
||||
|
||||
-- Get color for contact based on name
|
||||
local function getAvatarColor(name)
|
||||
local sum = 0
|
||||
for i = 1, #name do
|
||||
sum = sum + string.byte(name, i)
|
||||
end
|
||||
return avatar_colors[(sum % #avatar_colors) + 1]
|
||||
end
|
||||
|
||||
-- Initialize contacts data
|
||||
local function initContactsData()
|
||||
contacts_data = {
|
||||
{id = "1", name = "Alice Johnson", phone = "+1 555-0101", email = "alice@email.com", company = "Tech Corp"},
|
||||
{id = "2", name = "Andrew Smith", phone = "+1 555-0102", email = "andrew@email.com", company = "Design Studio"},
|
||||
{id = "3", name = "Bob Williams", phone = "+1 555-0201", email = "bob@email.com", company = ""},
|
||||
{id = "4", name = "Carol Davis", phone = "+1 555-0301", email = "carol.d@email.com", company = "Marketing Inc"},
|
||||
{id = "5", name = "Chris Miller", phone = "+1 555-0302", email = "", company = ""},
|
||||
{id = "6", name = "David Brown", phone = "+1 555-0401", email = "david.b@email.com", company = "Finance LLC"},
|
||||
{id = "7", name = "Emma Wilson", phone = "+1 555-0501", email = "emma@email.com", company = "Creative Agency"},
|
||||
{id = "8", name = "Frank Garcia", phone = "+1 555-0601", email = "", company = ""},
|
||||
{id = "9", name = "Grace Lee", phone = "+1 555-0701", email = "grace.lee@email.com", company = "Healthcare Plus"},
|
||||
{id = "10", name = "Henry Taylor", phone = "+1 555-0801", email = "henry@email.com", company = ""},
|
||||
{id = "11", name = "Isabella Martinez", phone = "+1 555-0901", email = "isabella@email.com", company = "Education Center"},
|
||||
{id = "12", name = "John Doe", phone = "+1 555-1234", email = "john.doe@email.com", company = "Software Inc"},
|
||||
{id = "13", name = "Kate Thompson", phone = "+1 555-1101", email = "", company = "Legal Partners"},
|
||||
{id = "14", name = "Liam Anderson", phone = "+1 555-1201", email = "liam.a@email.com", company = ""},
|
||||
{id = "15", name = "Mary Taylor", phone = "+1 555-0601", email = "mary@email.com", company = "Consulting Group"},
|
||||
{id = "16", name = "Michael Lee", phone = "+1 555-0602", email = "michael.l@email.com", company = ""},
|
||||
{id = "17", name = "Noah White", phone = "+1 555-1401", email = "noah@email.com", company = "Real Estate Co"},
|
||||
{id = "18", name = "Olivia Harris", phone = "+1 555-1501", email = "", company = ""},
|
||||
{id = "19", name = "Peter Clark", phone = "+1 555-1601", email = "peter.c@email.com", company = "Manufacturing Ltd"},
|
||||
{id = "20", name = "Sarah Anderson", phone = "+1 555-0701", email = "sarah@email.com", company = "Media Group"},
|
||||
}
|
||||
|
||||
-- Sort by name
|
||||
table.sort(contacts_data, function(a, b)
|
||||
return a.name:lower() < b.name:lower()
|
||||
end)
|
||||
|
||||
filtered_contacts = contacts_data
|
||||
end
|
||||
|
||||
-- Initialize contacts
|
||||
function initContacts(doc)
|
||||
print("[Contacts] Initializing...")
|
||||
contacts_doc = doc
|
||||
initContactsData()
|
||||
renderContacts()
|
||||
end
|
||||
|
||||
-- Render contacts list grouped by first letter
|
||||
function renderContacts()
|
||||
if not contacts_doc then return end
|
||||
|
||||
local container = contacts_doc:GetElementById("contacts-list")
|
||||
if not container then return end
|
||||
|
||||
local html = ""
|
||||
local current_letter = ""
|
||||
|
||||
for _, contact in ipairs(filtered_contacts) do
|
||||
local first_letter = contact.name:sub(1, 1):upper()
|
||||
|
||||
-- Add letter header if new letter
|
||||
if first_letter ~= current_letter then
|
||||
current_letter = first_letter
|
||||
html = html .. [[
|
||||
<div class="contact-letter">]] .. first_letter .. [[</div>
|
||||
]]
|
||||
end
|
||||
|
||||
-- Get avatar color and initial
|
||||
local color = getAvatarColor(contact.name)
|
||||
local initial = contact.name:sub(1, 1):upper()
|
||||
|
||||
html = html .. [[
|
||||
<div class="contact-item" onclick="selectContact(']] .. contact.id .. [[')">
|
||||
<div class="contact-avatar" style="background-color: ]] .. color .. [[;">]] .. initial .. [[</div>
|
||||
<div class="contact-info">
|
||||
<div class="contact-name">]] .. contact.name .. [[</div>
|
||||
<div class="contact-phone">]] .. contact.phone .. [[</div>
|
||||
</div>
|
||||
<div class="contact-call-btn" onclick="callContact(']] .. contact.id .. [['); event.stopPropagation();">
|
||||
<img src="../../icons/phone.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
|
||||
if #filtered_contacts == 0 then
|
||||
html = [[
|
||||
<div style="text-align: center; padding: 40px; color: #888888;">
|
||||
<div style="font-size: 18px;">No contacts found</div>
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
|
||||
container.inner_rml = html
|
||||
end
|
||||
|
||||
-- Search contacts
|
||||
function searchContacts(query)
|
||||
print("[Contacts] Searching: " .. query)
|
||||
search_query = query:lower()
|
||||
|
||||
if search_query == "" then
|
||||
filtered_contacts = contacts_data
|
||||
else
|
||||
filtered_contacts = {}
|
||||
for _, contact in ipairs(contacts_data) do
|
||||
if contact.name:lower():find(search_query, 1, true) or
|
||||
contact.phone:find(search_query, 1, true) or
|
||||
(contact.email and contact.email:lower():find(search_query, 1, true)) then
|
||||
table.insert(filtered_contacts, contact)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
renderContacts()
|
||||
end
|
||||
|
||||
-- Handle search input
|
||||
function onSearchInput(element)
|
||||
local query = element.value or ""
|
||||
searchContacts(query)
|
||||
end
|
||||
|
||||
-- Select a contact to view details
|
||||
function selectContact(contact_id)
|
||||
print("[Contacts] Selected contact: " .. contact_id)
|
||||
|
||||
-- Find contact by ID
|
||||
for _, contact in ipairs(contacts_data) do
|
||||
if contact.id == contact_id then
|
||||
selected_contact = contact
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if selected_contact then
|
||||
showContactDetail()
|
||||
end
|
||||
end
|
||||
|
||||
-- Show contact detail view
|
||||
function showContactDetail()
|
||||
if not selected_contact or not contacts_doc then return end
|
||||
|
||||
-- Store contact info for detail screen
|
||||
if mosis and mosis.state then
|
||||
mosis.state.set("selected_contact", selected_contact)
|
||||
end
|
||||
|
||||
-- Navigate to detail screen
|
||||
if navigateTo then
|
||||
navigateTo("contact_detail")
|
||||
else
|
||||
-- Show inline detail
|
||||
showContactDetailInline()
|
||||
end
|
||||
end
|
||||
|
||||
-- Show contact detail inline (if navigation not available)
|
||||
function showContactDetailInline()
|
||||
if not contacts_doc then return end
|
||||
|
||||
local detail = contacts_doc:GetElementById("contact-detail")
|
||||
local list = contacts_doc:GetElementById("contacts-list-container")
|
||||
|
||||
if detail and list then
|
||||
list.style.display = "none"
|
||||
detail.style.display = "flex"
|
||||
renderContactDetail()
|
||||
end
|
||||
end
|
||||
|
||||
-- Render contact detail
|
||||
function renderContactDetail()
|
||||
if not selected_contact or not contacts_doc then return end
|
||||
|
||||
local color = getAvatarColor(selected_contact.name)
|
||||
local initial = selected_contact.name:sub(1, 1):upper()
|
||||
|
||||
local detail_avatar = contacts_doc:GetElementById("detail-avatar")
|
||||
local detail_name = contacts_doc:GetElementById("detail-name")
|
||||
local detail_info = contacts_doc:GetElementById("detail-info")
|
||||
|
||||
if detail_avatar then
|
||||
detail_avatar.style["background-color"] = color
|
||||
detail_avatar.inner_rml = initial
|
||||
end
|
||||
|
||||
if detail_name then
|
||||
detail_name.inner_rml = selected_contact.name
|
||||
end
|
||||
|
||||
if detail_info then
|
||||
local html = ""
|
||||
|
||||
-- Phone
|
||||
html = html .. [[
|
||||
<div class="detail-row" onclick="callContact(']] .. selected_contact.id .. [[')">
|
||||
<div class="detail-icon"><img src="../../icons/phone.tga" style="width: 24px; height: 24px;"/></div>
|
||||
<div class="detail-content">
|
||||
<div class="detail-label">Phone</div>
|
||||
<div class="detail-value">]] .. selected_contact.phone .. [[</div>
|
||||
</div>
|
||||
<div class="detail-action"><img src="../../icons/call_small.tga" style="width: 24px; height: 24px;"/></div>
|
||||
</div>
|
||||
]]
|
||||
|
||||
-- Email
|
||||
if selected_contact.email and selected_contact.email ~= "" then
|
||||
html = html .. [[
|
||||
<div class="detail-row">
|
||||
<div class="detail-icon"><img src="../../icons/email.tga" style="width: 24px; height: 24px;"/></div>
|
||||
<div class="detail-content">
|
||||
<div class="detail-label">Email</div>
|
||||
<div class="detail-value">]] .. selected_contact.email .. [[</div>
|
||||
</div>
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
|
||||
-- Company
|
||||
if selected_contact.company and selected_contact.company ~= "" then
|
||||
html = html .. [[
|
||||
<div class="detail-row">
|
||||
<div class="detail-icon"><img src="../../icons/work.tga" style="width: 24px; height: 24px;"/></div>
|
||||
<div class="detail-content">
|
||||
<div class="detail-label">Company</div>
|
||||
<div class="detail-value">]] .. selected_contact.company .. [[</div>
|
||||
</div>
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
|
||||
detail_info.inner_rml = html
|
||||
end
|
||||
end
|
||||
|
||||
-- Hide contact detail
|
||||
function hideContactDetail()
|
||||
if not contacts_doc then return end
|
||||
|
||||
local detail = contacts_doc:GetElementById("contact-detail")
|
||||
local list = contacts_doc:GetElementById("contacts-list-container")
|
||||
|
||||
if detail and list then
|
||||
detail.style.display = "none"
|
||||
list.style.display = "flex"
|
||||
end
|
||||
|
||||
selected_contact = nil
|
||||
end
|
||||
|
||||
-- Call a contact
|
||||
function callContact(contact_id)
|
||||
print("[Contacts] Calling contact: " .. contact_id)
|
||||
|
||||
local contact = nil
|
||||
for _, c in ipairs(contacts_data) do
|
||||
if c.id == contact_id then
|
||||
contact = c
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if contact then
|
||||
-- Store call info
|
||||
if mosis and mosis.state then
|
||||
mosis.state.set("current_call", {
|
||||
number = contact.phone,
|
||||
name = contact.name
|
||||
})
|
||||
end
|
||||
|
||||
-- Navigate to calling screen
|
||||
if navigateTo then
|
||||
navigateTo("calling")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Calling " .. contact.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Message a contact
|
||||
function messageContact(contact_id)
|
||||
print("[Contacts] Messaging contact: " .. contact_id)
|
||||
|
||||
local contact = nil
|
||||
for _, c in ipairs(contacts_data) do
|
||||
if c.id == contact_id then
|
||||
contact = c
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if contact then
|
||||
if mosis and mosis.state then
|
||||
mosis.state.set("chat_contact", {
|
||||
name = contact.name,
|
||||
phone = contact.phone
|
||||
})
|
||||
end
|
||||
|
||||
if navigateTo then
|
||||
navigateTo("chat")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Message " .. contact.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Add new contact
|
||||
function addContact()
|
||||
print("[Contacts] Add new contact")
|
||||
if navigateTo then
|
||||
navigateTo("add_contact")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Add contact")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Edit contact
|
||||
function editContact(contact_id)
|
||||
print("[Contacts] Edit contact: " .. contact_id)
|
||||
if showToast then
|
||||
showToast("Edit contact")
|
||||
end
|
||||
end
|
||||
|
||||
-- Delete contact
|
||||
function deleteContact(contact_id)
|
||||
print("[Contacts] Delete contact: " .. contact_id)
|
||||
|
||||
-- Find and remove contact
|
||||
for i, c in ipairs(contacts_data) do
|
||||
if c.id == contact_id then
|
||||
table.remove(contacts_data, i)
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
-- Re-filter and render
|
||||
searchContacts(search_query)
|
||||
hideContactDetail()
|
||||
|
||||
if showToast then
|
||||
showToast("Contact deleted")
|
||||
end
|
||||
end
|
||||
352
base-apps/com.mosis.contacts/contacts.rml
Normal file
352
base-apps/com.mosis.contacts/contacts.rml
Normal file
@@ -0,0 +1,352 @@
|
||||
<rml>
|
||||
<head>
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
<script src="contacts.lua"></script>
|
||||
<title>Contacts</title>
|
||||
<style>
|
||||
.contacts-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.contact-letter {
|
||||
padding: 8px 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #BB86FC;
|
||||
background-color: #1E1E1E;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contact-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.contact-item:active {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.contact-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
margin-right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
font-size: 18px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.contact-phone {
|
||||
font-size: 16px;
|
||||
color: #B3B3B3;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.contact-call-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.contact-call-btn:hover {
|
||||
background-color: rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
.contact-call-btn:active {
|
||||
background-color: rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.contact-call-btn img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Contact Detail View */
|
||||
#contact-detail {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
background-color: #1E1E1E;
|
||||
}
|
||||
|
||||
.detail-avatar {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 48px;
|
||||
margin: 0 auto 16px auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
padding: 20px;
|
||||
background-color: #1E1E1E;
|
||||
border-bottom: 1px solid #333333;
|
||||
}
|
||||
|
||||
.detail-action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.detail-action-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.detail-action-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
background-color: #BB86FC;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-action-icon img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.detail-action-label {
|
||||
font-size: 14px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.detail-info {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #333333;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.detail-row:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.detail-icon {
|
||||
width: 40px;
|
||||
margin-right: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 14px;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 18px;
|
||||
color: #FFFFFF;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.detail-action {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Phone app bottom tabs */
|
||||
.phone-tabs {
|
||||
height: 72px;
|
||||
background-color: #1E1E1E;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.phone-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.phone-tab:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.phone-tab.active {
|
||||
color: #BB86FC;
|
||||
}
|
||||
|
||||
.phone-tab img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.phone-tab span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Search style adjustments */
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background-color: transparent;
|
||||
font-size: 18px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="app-screen" onload="initLayout(document); initContacts(document)" data-model="contacts">
|
||||
<!-- System Status Bar -->
|
||||
<div class="system-status-bar">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Bar -->
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-back" onclick="goBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<span class="app-bar-title">Contacts</span>
|
||||
<div class="app-bar-actions">
|
||||
<div class="app-bar-action" onclick="addContact()">
|
||||
<img src="../../icons/add.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contacts List Container -->
|
||||
<div id="contacts-list-container" class="app-content" style="display: flex; flex-direction: column;">
|
||||
<!-- Search Bar -->
|
||||
<div class="search-bar">
|
||||
<img src="../../icons/search.tga" class="search-icon" style="width: 24px; height: 24px;"/>
|
||||
<input class="search-input" type="text" placeholder="Search contacts" onchange="onSearchInput(this)"/>
|
||||
</div>
|
||||
|
||||
<!-- Contacts List -->
|
||||
<div class="contacts-list" id="contacts-list">
|
||||
<!-- Populated by Lua -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Detail View -->
|
||||
<div id="contact-detail">
|
||||
<div class="detail-header">
|
||||
<div class="detail-avatar" id="detail-avatar">A</div>
|
||||
<div class="detail-name" id="detail-name">Contact Name</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-actions">
|
||||
<div class="detail-action-btn" onclick="callContact(selected_contact and selected_contact.id or '')">
|
||||
<div class="detail-action-icon">
|
||||
<img src="../../icons/call_small.tga"/>
|
||||
</div>
|
||||
<span class="detail-action-label">Call</span>
|
||||
</div>
|
||||
<div class="detail-action-btn" onclick="messageContact(selected_contact and selected_contact.id or '')">
|
||||
<div class="detail-action-icon" style="background-color: #03DAC6;">
|
||||
<img src="../../icons/message.tga"/>
|
||||
</div>
|
||||
<span class="detail-action-label">Message</span>
|
||||
</div>
|
||||
<div class="detail-action-btn" onclick="editContact(selected_contact and selected_contact.id or '')">
|
||||
<div class="detail-action-icon" style="background-color: #FF9800;">
|
||||
<img src="../../icons/edit.tga"/>
|
||||
</div>
|
||||
<span class="detail-action-label">Edit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-info" id="detail-info">
|
||||
<!-- Populated by Lua -->
|
||||
</div>
|
||||
|
||||
<div style="padding: 16px;">
|
||||
<div class="btn btn-outlined" style="width: 100%; text-align: center;" onclick="hideContactDetail()">
|
||||
Back to Contacts
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAB -->
|
||||
<div class="btn-fab" onclick="addContact()">
|
||||
<img src="../../icons/add.tga" style="width: 32px; height: 32px;"/>
|
||||
</div>
|
||||
|
||||
<!-- Phone App Bottom Tabs -->
|
||||
<div class="phone-tabs">
|
||||
<div class="phone-tab" onclick="navigateTo('dialer')">
|
||||
<img src="../../icons/dialpad.tga"/>
|
||||
<span>Keypad</span>
|
||||
</div>
|
||||
<div class="phone-tab" onclick="switchTab('recent')">
|
||||
<img src="../../icons/history.tga"/>
|
||||
<span>Recent</span>
|
||||
</div>
|
||||
<div class="phone-tab active">
|
||||
<img src="../../icons/contacts.tga"/>
|
||||
<span>Contacts</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</rml>
|
||||
18
base-apps/com.mosis.contacts/manifest.json
Normal file
18
base-apps/com.mosis.contacts/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "com.mosis.contacts",
|
||||
"name": "Contacts",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "contacts.rml",
|
||||
"icon": "../../icons/contacts.tga",
|
||||
"description": "Contact list and management",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [
|
||||
"contacts"
|
||||
],
|
||||
"min_api_version": 1
|
||||
}
|
||||
227
base-apps/com.mosis.dialer/calling.lua
Normal file
227
base-apps/com.mosis.dialer/calling.lua
Normal file
@@ -0,0 +1,227 @@
|
||||
-- calling.lua - In-call screen functionality
|
||||
-- Handles call state, duration timer, and call controls
|
||||
|
||||
local calling_doc = nil
|
||||
local call_state = "connecting" -- connecting, active, ended
|
||||
local call_start_time = 0
|
||||
local call_duration = 0
|
||||
local timer_id = nil
|
||||
local is_muted = false
|
||||
local is_speaker = false
|
||||
local is_on_hold = false
|
||||
|
||||
-- Call info
|
||||
local call_number = ""
|
||||
local call_name = ""
|
||||
|
||||
-- Initialize calling screen
|
||||
function initCalling(doc)
|
||||
print("[Calling] Initializing...")
|
||||
calling_doc = doc
|
||||
|
||||
-- Get call info from state or use defaults
|
||||
if mosis and mosis.state then
|
||||
local call_info = mosis.state.get("current_call")
|
||||
if call_info then
|
||||
call_number = call_info.number or ""
|
||||
call_name = call_info.name or call_number
|
||||
end
|
||||
end
|
||||
|
||||
-- Fallback test data
|
||||
if call_number == "" then
|
||||
call_number = "+1 555-0101"
|
||||
call_name = "Alice Johnson"
|
||||
end
|
||||
|
||||
-- Update UI
|
||||
updateCallInfo()
|
||||
|
||||
-- Simulate connection after delay
|
||||
if setTimeout then
|
||||
setTimeout(function()
|
||||
setCallState("active")
|
||||
end, 2000)
|
||||
else
|
||||
setCallState("active")
|
||||
end
|
||||
end
|
||||
|
||||
-- Update call info display
|
||||
function updateCallInfo()
|
||||
if not calling_doc then return end
|
||||
|
||||
local name_el = calling_doc:GetElementById("call-name")
|
||||
local number_el = calling_doc:GetElementById("call-number")
|
||||
local status_el = calling_doc:GetElementById("call-status")
|
||||
local avatar_el = calling_doc:GetElementById("call-avatar")
|
||||
|
||||
if name_el then
|
||||
name_el.inner_rml = call_name
|
||||
end
|
||||
|
||||
if number_el then
|
||||
number_el.inner_rml = call_number
|
||||
end
|
||||
|
||||
if avatar_el then
|
||||
-- Get first letter for avatar
|
||||
local initial = call_name:sub(1, 1):upper()
|
||||
avatar_el.inner_rml = initial
|
||||
end
|
||||
end
|
||||
|
||||
-- Set call state
|
||||
function setCallState(state)
|
||||
print("[Calling] State changed to: " .. state)
|
||||
call_state = state
|
||||
|
||||
local status_el = calling_doc:GetElementById("call-status")
|
||||
local timer_el = calling_doc:GetElementById("call-timer")
|
||||
|
||||
if state == "connecting" then
|
||||
if status_el then
|
||||
status_el.inner_rml = "Calling..."
|
||||
end
|
||||
if timer_el then
|
||||
timer_el.style.display = "none"
|
||||
end
|
||||
elseif state == "active" then
|
||||
if status_el then
|
||||
status_el.inner_rml = "Connected"
|
||||
end
|
||||
if timer_el then
|
||||
timer_el.style.display = "block"
|
||||
end
|
||||
-- Start duration timer
|
||||
startCallTimer()
|
||||
elseif state == "ended" then
|
||||
if status_el then
|
||||
status_el.inner_rml = "Call ended"
|
||||
end
|
||||
stopCallTimer()
|
||||
-- Return to dialer after delay
|
||||
if setTimeout then
|
||||
setTimeout(function()
|
||||
if goBack then
|
||||
goBack()
|
||||
end
|
||||
end, 1500)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Start call duration timer
|
||||
function startCallTimer()
|
||||
call_start_time = os.time and os.time() or 0
|
||||
call_duration = 0
|
||||
|
||||
if setInterval then
|
||||
timer_id = setInterval(function()
|
||||
call_duration = call_duration + 1
|
||||
updateTimerDisplay()
|
||||
end, 1000)
|
||||
end
|
||||
end
|
||||
|
||||
-- Stop call timer
|
||||
function stopCallTimer()
|
||||
if timer_id and clearInterval then
|
||||
clearInterval(timer_id)
|
||||
timer_id = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Update timer display
|
||||
function updateTimerDisplay()
|
||||
local timer_el = calling_doc:GetElementById("call-timer")
|
||||
if timer_el then
|
||||
local minutes = math.floor(call_duration / 60)
|
||||
local seconds = call_duration % 60
|
||||
timer_el.inner_rml = string.format("%02d:%02d", minutes, seconds)
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle mute
|
||||
function toggleMute()
|
||||
is_muted = not is_muted
|
||||
print("[Calling] Mute: " .. tostring(is_muted))
|
||||
|
||||
local mute_btn = calling_doc:GetElementById("btn-mute")
|
||||
if mute_btn then
|
||||
if is_muted then
|
||||
mute_btn:SetClass("active", true)
|
||||
else
|
||||
mute_btn:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
|
||||
if showToast then
|
||||
showToast(is_muted and "Muted" or "Unmuted")
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle speaker
|
||||
function toggleSpeaker()
|
||||
is_speaker = not is_speaker
|
||||
print("[Calling] Speaker: " .. tostring(is_speaker))
|
||||
|
||||
local speaker_btn = calling_doc:GetElementById("btn-speaker")
|
||||
if speaker_btn then
|
||||
if is_speaker then
|
||||
speaker_btn:SetClass("active", true)
|
||||
else
|
||||
speaker_btn:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
|
||||
if showToast then
|
||||
showToast(is_speaker and "Speaker on" or "Speaker off")
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle hold
|
||||
function toggleHold()
|
||||
is_on_hold = not is_on_hold
|
||||
print("[Calling] Hold: " .. tostring(is_on_hold))
|
||||
|
||||
local hold_btn = calling_doc:GetElementById("btn-hold")
|
||||
if hold_btn then
|
||||
if is_on_hold then
|
||||
hold_btn:SetClass("active", true)
|
||||
else
|
||||
hold_btn:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
|
||||
local status_el = calling_doc:GetElementById("call-status")
|
||||
if status_el then
|
||||
if is_on_hold then
|
||||
status_el.inner_rml = "On hold"
|
||||
else
|
||||
status_el.inner_rml = "Connected"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Show dialpad
|
||||
function showDialpad()
|
||||
print("[Calling] Show dialpad")
|
||||
if showToast then
|
||||
showToast("Dialpad")
|
||||
end
|
||||
end
|
||||
|
||||
-- Add call (conference)
|
||||
function addCall()
|
||||
print("[Calling] Add call")
|
||||
if showToast then
|
||||
showToast("Add call")
|
||||
end
|
||||
end
|
||||
|
||||
-- End call
|
||||
function endCall()
|
||||
print("[Calling] Ending call")
|
||||
setCallState("ended")
|
||||
end
|
||||
191
base-apps/com.mosis.dialer/calling.rml
Normal file
191
base-apps/com.mosis.dialer/calling.rml
Normal file
@@ -0,0 +1,191 @@
|
||||
<rml>
|
||||
<head>
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="calling.lua"></script>
|
||||
<title>Calling</title>
|
||||
<style>
|
||||
.calling-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, #1a237e 0%, #000000 100%);
|
||||
background-color: #1a237e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.calling-header {
|
||||
padding-top: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.call-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 60px;
|
||||
background-color: #BB86FC;
|
||||
margin: 0 auto 24px auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.call-name {
|
||||
font-size: 32px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.call-number {
|
||||
font-size: 18px;
|
||||
color: #B3B3B3;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.call-status {
|
||||
font-size: 18px;
|
||||
color: #4CAF50;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.call-timer {
|
||||
font-size: 24px;
|
||||
color: #FFFFFF;
|
||||
font-weight: 300;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calling-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.call-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
gap: 32px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.call-control-btn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 36px;
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.call-control-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.call-control-btn.active {
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.call-control-btn img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.call-control-label {
|
||||
font-size: 12px;
|
||||
color: #FFFFFF;
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.call-control-btn.active .call-control-label {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.end-call-container {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.end-call-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 40px;
|
||||
background-color: #F44336;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.end-call-btn:hover {
|
||||
background-color: #E53935;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.end-call-btn:active {
|
||||
background-color: #C62828;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.end-call-btn img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="calling-screen" onload="initCalling(document)">
|
||||
<!-- Caller Info -->
|
||||
<div class="calling-header">
|
||||
<div class="call-avatar" id="call-avatar">A</div>
|
||||
<div class="call-name" id="call-name">Alice Johnson</div>
|
||||
<div class="call-number" id="call-number">+1 555-0101</div>
|
||||
<div class="call-status" id="call-status">Calling...</div>
|
||||
<div class="call-timer" id="call-timer">00:00</div>
|
||||
</div>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="calling-content"></div>
|
||||
|
||||
<!-- Call Controls -->
|
||||
<div class="call-controls">
|
||||
<div id="btn-mute" class="call-control-btn" onclick="toggleMute()">
|
||||
<img src="../../icons/mic_off.tga"/>
|
||||
<span class="call-control-label">Mute</span>
|
||||
</div>
|
||||
<div id="btn-keypad" class="call-control-btn" onclick="showDialpad()">
|
||||
<img src="../../icons/dialpad.tga"/>
|
||||
<span class="call-control-label">Keypad</span>
|
||||
</div>
|
||||
<div id="btn-speaker" class="call-control-btn" onclick="toggleSpeaker()">
|
||||
<img src="../../icons/volume.tga"/>
|
||||
<span class="call-control-label">Speaker</span>
|
||||
</div>
|
||||
<div id="btn-add" class="call-control-btn" onclick="addCall()">
|
||||
<img src="../../icons/add.tga"/>
|
||||
<span class="call-control-label">Add call</span>
|
||||
</div>
|
||||
<div id="btn-hold" class="call-control-btn" onclick="toggleHold()">
|
||||
<img src="../../icons/pause.tga"/>
|
||||
<span class="call-control-label">Hold</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- End Call Button -->
|
||||
<div class="end-call-container">
|
||||
<div class="end-call-btn" onclick="endCall()">
|
||||
<img src="../../icons/call_end.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</rml>
|
||||
235
base-apps/com.mosis.dialer/dialer.lua
Normal file
235
base-apps/com.mosis.dialer/dialer.lua
Normal file
@@ -0,0 +1,235 @@
|
||||
-- dialer.lua - Phone dialer functionality
|
||||
-- Handles dial pad input, call management, and call history
|
||||
|
||||
local dialer_doc = nil
|
||||
local dial_number = ""
|
||||
local call_history = {}
|
||||
local current_tab = "keypad" -- keypad, recent, contacts
|
||||
|
||||
-- Sample call history data
|
||||
local function initCallHistory()
|
||||
call_history = {
|
||||
{name = "Alice Johnson", number = "+1 555-0101", type = "incoming", time = "2:34 PM", duration = "5:23"},
|
||||
{name = "Bob Williams", number = "+1 555-0201", type = "outgoing", time = "1:15 PM", duration = "2:45"},
|
||||
{name = "Carol Davis", number = "+1 555-0301", type = "missed", time = "Yesterday", duration = nil},
|
||||
{name = "David Brown", number = "+1 555-0401", type = "incoming", time = "Yesterday", duration = "12:30"},
|
||||
{name = "Emma Wilson", number = "+1 555-0501", type = "outgoing", time = "Mon", duration = "3:15"},
|
||||
{name = "+1 555-9999", number = "+1 555-9999", type = "missed", time = "Mon", duration = nil},
|
||||
{name = "John Doe", number = "+1 555-1234", type = "incoming", time = "Sun", duration = "8:42"},
|
||||
}
|
||||
end
|
||||
|
||||
-- Initialize dialer
|
||||
function initDialer(doc)
|
||||
print("[Dialer] Initializing...")
|
||||
dialer_doc = doc
|
||||
dial_number = ""
|
||||
initCallHistory()
|
||||
updateDialDisplay()
|
||||
end
|
||||
|
||||
-- Update the dial display
|
||||
function updateDialDisplay()
|
||||
if not dialer_doc then return end
|
||||
|
||||
local display = dialer_doc:GetElementById("dial-display")
|
||||
if display then
|
||||
if dial_number == "" then
|
||||
display.inner_rml = '<span style="color: #666666;">Enter number</span>'
|
||||
else
|
||||
-- Format number for display
|
||||
local formatted = formatPhoneNumber(dial_number)
|
||||
display.inner_rml = formatted
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Format phone number for display
|
||||
function formatPhoneNumber(number)
|
||||
local len = #number
|
||||
if len <= 3 then
|
||||
return number
|
||||
elseif len <= 6 then
|
||||
return number:sub(1,3) .. "-" .. number:sub(4)
|
||||
elseif len <= 10 then
|
||||
return "(" .. number:sub(1,3) .. ") " .. number:sub(4,6) .. "-" .. number:sub(7)
|
||||
else
|
||||
return "+1 (" .. number:sub(1,3) .. ") " .. number:sub(4,6) .. "-" .. number:sub(7,10)
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle dial key press
|
||||
function dial_press(key)
|
||||
print("[Dialer] Key pressed: " .. key)
|
||||
|
||||
if #dial_number < 15 then
|
||||
dial_number = dial_number .. key
|
||||
updateDialDisplay()
|
||||
|
||||
-- Play haptic/sound feedback if available
|
||||
if mosis and mosis.haptic then
|
||||
mosis.haptic.vibrate(10)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle backspace
|
||||
function dial_backspace()
|
||||
if #dial_number > 0 then
|
||||
dial_number = dial_number:sub(1, -2)
|
||||
updateDialDisplay()
|
||||
end
|
||||
end
|
||||
|
||||
-- Clear dial number
|
||||
function dial_clear()
|
||||
dial_number = ""
|
||||
updateDialDisplay()
|
||||
end
|
||||
|
||||
-- Make a call
|
||||
function make_call()
|
||||
if dial_number == "" then
|
||||
print("[Dialer] Cannot call: no number entered")
|
||||
if showToast then
|
||||
showToast("Enter a number to call")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
print("[Dialer] Calling: " .. dial_number)
|
||||
|
||||
-- Add to call history
|
||||
table.insert(call_history, 1, {
|
||||
name = dial_number,
|
||||
number = dial_number,
|
||||
type = "outgoing",
|
||||
time = "Just now",
|
||||
duration = nil
|
||||
})
|
||||
|
||||
-- Navigate to calling screen
|
||||
if navigateTo then
|
||||
-- Store call info for the calling screen
|
||||
if mosis and mosis.state then
|
||||
mosis.state.set("current_call", {
|
||||
number = dial_number,
|
||||
name = getContactName(dial_number),
|
||||
start_time = os.time and os.time() or 0
|
||||
})
|
||||
end
|
||||
navigateTo("calling")
|
||||
else
|
||||
-- Fallback: load calling screen directly
|
||||
local calling_path = dialer_doc:GetSourceURL():gsub("dialer.rml", "calling.rml")
|
||||
if mosis and mosis.loadDocument then
|
||||
mosis.loadDocument(calling_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Get contact name by number (returns number if not found)
|
||||
function getContactName(number)
|
||||
for _, call in ipairs(call_history) do
|
||||
if call.number == number and call.name ~= number then
|
||||
return call.name
|
||||
end
|
||||
end
|
||||
return number
|
||||
end
|
||||
|
||||
-- Switch tabs
|
||||
function switchTab(tab_name)
|
||||
print("[Dialer] Switching to tab: " .. tab_name)
|
||||
current_tab = tab_name
|
||||
|
||||
-- Update tab UI
|
||||
local tabs = {"keypad", "recent", "contacts"}
|
||||
for _, tab in ipairs(tabs) do
|
||||
local tab_el = dialer_doc:GetElementById("tab-" .. tab)
|
||||
if tab_el then
|
||||
if tab == tab_name then
|
||||
tab_el:SetClass("active", true)
|
||||
else
|
||||
tab_el:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Show/hide content
|
||||
local keypad_content = dialer_doc:GetElementById("keypad-content")
|
||||
local recent_content = dialer_doc:GetElementById("recent-content")
|
||||
|
||||
if keypad_content then
|
||||
keypad_content.style.display = (tab_name == "keypad") and "flex" or "none"
|
||||
end
|
||||
if recent_content then
|
||||
recent_content.style.display = (tab_name == "recent") and "block" or "none"
|
||||
end
|
||||
|
||||
-- Render recent calls if switching to that tab
|
||||
if tab_name == "recent" then
|
||||
renderCallHistory()
|
||||
end
|
||||
end
|
||||
|
||||
-- Render call history
|
||||
function renderCallHistory()
|
||||
local container = dialer_doc:GetElementById("recent-list")
|
||||
if not container then return end
|
||||
|
||||
local html = ""
|
||||
for _, call in ipairs(call_history) do
|
||||
local icon_color = "#4CAF50" -- incoming = green
|
||||
local icon = "phone.tga"
|
||||
|
||||
if call.type == "outgoing" then
|
||||
icon_color = "#2196F3" -- blue
|
||||
icon = "call_made.tga"
|
||||
elseif call.type == "missed" then
|
||||
icon_color = "#F44336" -- red
|
||||
icon = "call_missed.tga"
|
||||
end
|
||||
|
||||
local duration_text = call.duration or "Missed"
|
||||
|
||||
html = html .. [[
|
||||
<div class="call-history-item" onclick="callNumber(']] .. call.number .. [[')">
|
||||
<div class="call-history-icon" style="background-color: ]] .. icon_color .. [[;">
|
||||
<img src="../../icons/]] .. icon .. [[" style="width: 24px; height: 24px;"/>
|
||||
</div>
|
||||
<div class="call-history-info">
|
||||
<div class="call-history-name">]] .. call.name .. [[</div>
|
||||
<div class="call-history-meta">]] .. call.type .. " - " .. call.time .. [[</div>
|
||||
</div>
|
||||
<div class="call-history-time">]] .. duration_text .. [[</div>
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
|
||||
container.inner_rml = html
|
||||
end
|
||||
|
||||
-- Call a number from history
|
||||
function callNumber(number)
|
||||
dial_number = number:gsub("[^%d+]", "") -- Remove non-digit chars except +
|
||||
updateDialDisplay()
|
||||
switchTab("keypad")
|
||||
make_call()
|
||||
end
|
||||
|
||||
-- Long press on 0 for +
|
||||
function dial_long_press_zero()
|
||||
if dial_number == "" or dial_number:sub(-1) ~= "0" then
|
||||
dial_press("+")
|
||||
else
|
||||
-- Replace last 0 with +
|
||||
dial_number = dial_number:sub(1, -2) .. "+"
|
||||
updateDialDisplay()
|
||||
end
|
||||
end
|
||||
|
||||
-- Long press on * for pause
|
||||
function dial_long_press_star()
|
||||
dial_press(",") -- Comma is standard pause character
|
||||
end
|
||||
225
base-apps/com.mosis.dialer/dialer.rml
Normal file
225
base-apps/com.mosis.dialer/dialer.rml
Normal file
@@ -0,0 +1,225 @@
|
||||
<rml>
|
||||
<head>
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
<script src="dialer.lua"></script>
|
||||
<title>Phone</title>
|
||||
<style>
|
||||
.dialer-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Keypad content */
|
||||
#keypad-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Recent calls content */
|
||||
#recent-content {
|
||||
flex: 1;
|
||||
display: none;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.call-history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.call-history-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.call-history-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.call-history-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.call-history-name {
|
||||
font-size: 16px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.call-history-meta {
|
||||
font-size: 14px;
|
||||
color: #888888;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.call-history-time {
|
||||
font-size: 14px;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
/* Phone app bottom tabs */
|
||||
.phone-tabs {
|
||||
height: 72px;
|
||||
background-color: #1E1E1E;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.phone-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.phone-tab:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.phone-tab.active {
|
||||
color: #BB86FC;
|
||||
}
|
||||
|
||||
.phone-tab img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.phone-tab span {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="app-screen" onload="initLayout(document); initDialer(document)">
|
||||
<!-- System Status Bar -->
|
||||
<div class="system-status-bar">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Bar -->
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-back" onclick="goBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<span class="app-bar-title">Phone</span>
|
||||
</div>
|
||||
|
||||
<!-- Dialer Content -->
|
||||
<div class="dialer-content">
|
||||
<!-- Keypad View -->
|
||||
<div id="keypad-content">
|
||||
<!-- Dial Display -->
|
||||
<div class="dial-display" id="dial-display">
|
||||
<span style="color: #666666;">Enter number</span>
|
||||
</div>
|
||||
|
||||
<!-- Dial Pad -->
|
||||
<div class="dial-pad">
|
||||
<div class="dial-key" onclick="dial_press('1')">
|
||||
<span class="dial-key-number">1</span>
|
||||
<span class="dial-key-letters"></span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('2')">
|
||||
<span class="dial-key-number">2</span>
|
||||
<span class="dial-key-letters">ABC</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('3')">
|
||||
<span class="dial-key-number">3</span>
|
||||
<span class="dial-key-letters">DEF</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('4')">
|
||||
<span class="dial-key-number">4</span>
|
||||
<span class="dial-key-letters">GHI</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('5')">
|
||||
<span class="dial-key-number">5</span>
|
||||
<span class="dial-key-letters">JKL</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('6')">
|
||||
<span class="dial-key-number">6</span>
|
||||
<span class="dial-key-letters">MNO</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('7')">
|
||||
<span class="dial-key-number">7</span>
|
||||
<span class="dial-key-letters">PQRS</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('8')">
|
||||
<span class="dial-key-number">8</span>
|
||||
<span class="dial-key-letters">TUV</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('9')">
|
||||
<span class="dial-key-number">9</span>
|
||||
<span class="dial-key-letters">WXYZ</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('*')">
|
||||
<span class="dial-key-number">*</span>
|
||||
<span class="dial-key-letters"></span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('0')">
|
||||
<span class="dial-key-number">0</span>
|
||||
<span class="dial-key-letters">+</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('#')">
|
||||
<span class="dial-key-number">#</span>
|
||||
<span class="dial-key-letters"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Call Actions -->
|
||||
<div class="dial-actions">
|
||||
<div style="width: 56px;"></div>
|
||||
<div class="dial-call-btn" onclick="make_call()">
|
||||
<img src="../../icons/call_small.tga" style="width: 32px; height: 32px; pointer-events: none;"/>
|
||||
</div>
|
||||
<div class="btn-icon" onclick="dial_backspace()" style="width: 56px; height: 56px;">
|
||||
<img src="../../icons/backspace.tga" style="width: 32px; height: 32px; pointer-events: none;"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Calls View -->
|
||||
<div id="recent-content">
|
||||
<div id="recent-list">
|
||||
<!-- Populated by Lua -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phone App Bottom Tabs -->
|
||||
<div class="phone-tabs">
|
||||
<div id="tab-keypad" class="phone-tab active" onclick="switchTab('keypad')">
|
||||
<img src="../../icons/dialpad.tga"/>
|
||||
<span>Keypad</span>
|
||||
</div>
|
||||
<div id="tab-recent" class="phone-tab" onclick="switchTab('recent')">
|
||||
<img src="../../icons/history.tga"/>
|
||||
<span>Recent</span>
|
||||
</div>
|
||||
<div id="tab-contacts" class="phone-tab" onclick="navigateTo('contacts')">
|
||||
<img src="../../icons/contacts.tga"/>
|
||||
<span>Contacts</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</rml>
|
||||
18
base-apps/com.mosis.dialer/manifest.json
Normal file
18
base-apps/com.mosis.dialer/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "com.mosis.dialer",
|
||||
"name": "Phone",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "dialer.rml",
|
||||
"icon": "../../icons/phone.tga",
|
||||
"description": "Phone dialer and call interface",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [
|
||||
"phone"
|
||||
],
|
||||
"min_api_version": 1
|
||||
}
|
||||
200
base-apps/com.mosis.home/home.lua
Normal file
200
base-apps/com.mosis.home/home.lua
Normal file
@@ -0,0 +1,200 @@
|
||||
-- home.lua - Home screen dynamic app rendering
|
||||
-- Handles system apps and discovered third-party apps
|
||||
|
||||
-- System apps with their navigation keys and colors
|
||||
local system_apps = {
|
||||
-- Row 1
|
||||
{name = "Phone", icon = "phone", color = "#4CAF50", nav = "dialer"},
|
||||
{name = "Messages", icon = "message", color = "#2196F3", nav = "messages"},
|
||||
{name = "Contacts", icon = "contacts", color = "#FF9800", nav = "contacts"},
|
||||
{name = "Browser", icon = "browser", color = "#F44336", nav = "browser"},
|
||||
-- Row 2
|
||||
{name = "Gallery", icon = "gallery", color = "#9C27B0", nav = nil},
|
||||
{name = "Camera", icon = "camera", color = "#00BCD4", nav = "camera"},
|
||||
{name = "Settings", icon = "settings", color = "#607D8B", nav = "settings"},
|
||||
{name = "Music", icon = "music", color = "#E91E63", nav = "music"},
|
||||
-- Row 3
|
||||
{name = "Calendar", icon = "calendar", color = "#3F51B5", nav = nil},
|
||||
{name = "Clock", icon = "clock", color = "#009688", nav = nil},
|
||||
{name = "Notes", icon = "notes", color = "#795548", nav = nil},
|
||||
{name = "Maps", icon = "maps", color = "#FF5722", nav = nil},
|
||||
-- Row 4
|
||||
{name = "Store", icon = "store", color = "#8BC34A", nav = "store"},
|
||||
{name = "Files", icon = "files", color = "#CDDC39", nav = nil},
|
||||
{name = "Calculator", icon = "calculator", color = "#FFC107", nav = nil},
|
||||
{name = "Weather", icon = "weather", color = "#673AB7", nav = nil},
|
||||
}
|
||||
|
||||
-- State
|
||||
local installed_apps = {}
|
||||
local home_document = nil -- Store document reference
|
||||
|
||||
-- Initialize on load (receives document from onload event)
|
||||
function initHome(doc)
|
||||
print("[Home] Initializing home screen...")
|
||||
home_document = doc
|
||||
|
||||
-- Get installed third-party apps
|
||||
if mosis and mosis.apps then
|
||||
installed_apps = mosis.apps.getInstalled() or {}
|
||||
print("[Home] Found " .. #installed_apps .. " installed apps")
|
||||
|
||||
-- Filter to only third-party (non-system) apps
|
||||
local third_party = {}
|
||||
for _, app in ipairs(installed_apps) do
|
||||
if not app.is_system_app then
|
||||
table.insert(third_party, app)
|
||||
print("[Home] Third-party app: " .. app.name .. " (" .. app.package_id .. ")")
|
||||
end
|
||||
end
|
||||
installed_apps = third_party
|
||||
else
|
||||
print("[Home] Warning: mosis.apps API not available")
|
||||
installed_apps = {}
|
||||
end
|
||||
|
||||
-- Render dynamic apps
|
||||
renderThirdPartyApps()
|
||||
end
|
||||
|
||||
-- Generate a color based on package_id
|
||||
function getAppColor(package_id)
|
||||
local colors = {
|
||||
"#BB86FC", "#03DAC6", "#FF9800", "#2196F3",
|
||||
"#4CAF50", "#F44336", "#E91E63", "#3F51B5",
|
||||
"#009688", "#795548", "#FF5722", "#673AB7"
|
||||
}
|
||||
|
||||
-- Simple hash of package_id to pick a color
|
||||
local hash = 0
|
||||
for i = 1, #package_id do
|
||||
hash = hash + package_id:byte(i)
|
||||
end
|
||||
|
||||
return colors[(hash % #colors) + 1]
|
||||
end
|
||||
|
||||
-- Get first letter for placeholder icon
|
||||
function getAppInitial(name)
|
||||
return name:sub(1, 1):upper()
|
||||
end
|
||||
|
||||
-- Render third-party apps into the grid
|
||||
function renderThirdPartyApps()
|
||||
-- Use stored document reference
|
||||
if not home_document then
|
||||
print("[Home] Could not get document reference")
|
||||
return
|
||||
end
|
||||
|
||||
local grid = home_document:GetElementById("third-party-apps")
|
||||
if not grid then
|
||||
print("[Home] third-party-apps container not found")
|
||||
return
|
||||
end
|
||||
|
||||
-- Clear existing content
|
||||
grid.inner_rml = ""
|
||||
|
||||
if #installed_apps == 0 then
|
||||
print("[Home] No third-party apps to display")
|
||||
return
|
||||
end
|
||||
|
||||
-- Build HTML for each app
|
||||
local html = ""
|
||||
for _, app in ipairs(installed_apps) do
|
||||
local color = getAppColor(app.package_id)
|
||||
local initial = getAppInitial(app.name)
|
||||
local icon_html
|
||||
|
||||
-- Check if app has an icon
|
||||
if app.icon and app.icon ~= "" then
|
||||
local icon_path
|
||||
-- Check if icon is already a full path (starts with / or contains :/)
|
||||
if app.icon:sub(1, 1) == "/" or app.icon:find(":/") then
|
||||
-- Already a full path
|
||||
icon_path = app.icon
|
||||
elseif app.install_path and app.install_path ~= "" then
|
||||
-- Relative filename - construct full path from install_path
|
||||
icon_path = app.install_path .. "/" .. app.icon
|
||||
else
|
||||
icon_path = app.icon
|
||||
end
|
||||
-- Use file:// prefix for absolute paths to prevent RmlUi URL resolution
|
||||
local src_path = icon_path
|
||||
if icon_path:sub(1, 1) == "/" then
|
||||
src_path = "file://" .. icon_path
|
||||
end
|
||||
-- Use img tag for actual icon
|
||||
icon_html = '<img src="' .. src_path .. '" style="width: 48px; height: 48px;"/>'
|
||||
print("[Home] Loading icon: " .. src_path)
|
||||
else
|
||||
-- Fallback to initial letter
|
||||
icon_html = '<span style="font-size: 28px; color: #000000;">' .. initial .. '</span>'
|
||||
end
|
||||
|
||||
html = html .. [[
|
||||
<div class="app-icon">
|
||||
<div class="app-icon-image" style="background-color: ]] .. color .. [[;"
|
||||
onclick="launchThirdPartyApp(']] .. app.package_id .. [[')">
|
||||
]] .. icon_html .. [[
|
||||
</div>
|
||||
<span class="app-icon-label">]] .. app.name .. [[</span>
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
|
||||
grid.inner_rml = html
|
||||
print("[Home] Rendered " .. #installed_apps .. " third-party apps")
|
||||
end
|
||||
|
||||
-- Get app info by package_id
|
||||
function getAppInfo(package_id)
|
||||
for _, app in ipairs(installed_apps) do
|
||||
if app.package_id == package_id then
|
||||
return app
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Launch a third-party app
|
||||
function launchThirdPartyApp(package_id)
|
||||
print("[Home] Launching app: " .. package_id)
|
||||
|
||||
if mosis and mosis.apps then
|
||||
local success = mosis.apps.launch(package_id)
|
||||
if success then
|
||||
print("[Home] App sandbox started: " .. package_id)
|
||||
|
||||
-- Get app info for sandbox switching and UI loading
|
||||
local app_info = getAppInfo(package_id)
|
||||
if app_info and app_info.install_path and app_info.entry_point then
|
||||
-- Switch sandbox context to this app (registers timer, fs, json, crypto APIs)
|
||||
if switchAppSandbox then
|
||||
switchAppSandbox(package_id, app_info.install_path)
|
||||
print("[Home] Sandbox context switched to: " .. package_id)
|
||||
end
|
||||
|
||||
-- Now load the app's UI document
|
||||
local entry_path = app_info.install_path .. "/" .. app_info.entry_point
|
||||
print("[Home] Loading app screen: " .. entry_path)
|
||||
local loaded = loadScreen(entry_path)
|
||||
if loaded then
|
||||
print("[Home] App UI loaded: " .. package_id)
|
||||
else
|
||||
print("[Home] Failed to load app UI: " .. entry_path)
|
||||
end
|
||||
else
|
||||
print("[Home] App info missing entry point: " .. package_id)
|
||||
end
|
||||
else
|
||||
print("[Home] Failed to launch app: " .. package_id)
|
||||
end
|
||||
else
|
||||
print("[Home] Cannot launch app: mosis.apps not available")
|
||||
end
|
||||
end
|
||||
|
||||
-- initHome() is called via onload in home.rml
|
||||
@@ -4,6 +4,7 @@
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="home.lua"></script>
|
||||
<title>Virtual Smartphone - Home</title>
|
||||
<style>
|
||||
.home-screen {
|
||||
@@ -34,9 +35,44 @@
|
||||
.status-bar-icons img {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Third-party apps section */
|
||||
.app-grid-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Third-party apps use same sizing as system apps */
|
||||
#third-party-apps .app-icon {
|
||||
width: 25%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#third-party-apps .app-icon-image {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 12px auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#third-party-apps .app-icon-image:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
#third-party-apps .app-icon-label {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="home-screen">
|
||||
<body class="home-screen" onload="initHome(document)">
|
||||
<!-- Status Bar -->
|
||||
<div class="status-bar">
|
||||
<span class="status-bar-time">12:30</span>
|
||||
@@ -121,6 +157,11 @@
|
||||
<div class="app-icon-image" style="background-color: #673AB7;"><img src="../../icons/weather.tga"/></div>
|
||||
<span class="app-icon-label">Weather</span>
|
||||
</div>
|
||||
|
||||
<!-- Third-party apps (dynamically populated by home.lua) -->
|
||||
<div id="third-party-apps" class="app-grid-section">
|
||||
<!-- Apps will be rendered here by home.lua -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
16
base-apps/com.mosis.home/manifest.json
Normal file
16
base-apps/com.mosis.home/manifest.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "com.mosis.home",
|
||||
"name": "Home",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "home.rml",
|
||||
"icon": "../../icons/home.tga",
|
||||
"description": "Mosis home screen and app launcher",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [],
|
||||
"min_api_version": 1
|
||||
}
|
||||
18
base-apps/com.mosis.messages/manifest.json
Normal file
18
base-apps/com.mosis.messages/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "com.mosis.messages",
|
||||
"name": "Messages",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "messages.rml",
|
||||
"icon": "../../icons/message.tga",
|
||||
"description": "SMS and messaging application",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [
|
||||
"sms"
|
||||
],
|
||||
"min_api_version": 1
|
||||
}
|
||||
398
base-apps/com.mosis.messages/messages.lua
Normal file
398
base-apps/com.mosis.messages/messages.lua
Normal file
@@ -0,0 +1,398 @@
|
||||
-- messages.lua - Messages app functionality
|
||||
-- Handles conversation list and individual chats
|
||||
|
||||
local messages_doc = nil
|
||||
local conversations = {}
|
||||
local current_conversation = nil
|
||||
local current_messages = {}
|
||||
|
||||
-- Avatar colors
|
||||
local avatar_colors = {
|
||||
"#E91E63", "#9C27B0", "#673AB7", "#3F51B5", "#2196F3",
|
||||
"#03A9F4", "#00BCD4", "#009688", "#4CAF50", "#FF9800"
|
||||
}
|
||||
|
||||
local function getAvatarColor(name)
|
||||
local sum = 0
|
||||
for i = 1, #name do
|
||||
sum = sum + string.byte(name, i)
|
||||
end
|
||||
return avatar_colors[(sum % #avatar_colors) + 1]
|
||||
end
|
||||
|
||||
-- Initialize conversations data
|
||||
local function initConversationsData()
|
||||
conversations = {
|
||||
{
|
||||
id = "1",
|
||||
name = "Alice Johnson",
|
||||
phone = "+1 555-0101",
|
||||
last_message = "Hey! Are you coming to the party tonight?",
|
||||
time = "2:34 PM",
|
||||
unread = 2,
|
||||
messages = {
|
||||
{sender = "them", text = "Hey!", time = "2:30 PM"},
|
||||
{sender = "them", text = "What are you up to?", time = "2:31 PM"},
|
||||
{sender = "me", text = "Not much, just working", time = "2:32 PM"},
|
||||
{sender = "them", text = "Cool! There's a party at Mike's tonight", time = "2:33 PM"},
|
||||
{sender = "them", text = "Hey! Are you coming to the party tonight?", time = "2:34 PM"},
|
||||
}
|
||||
},
|
||||
{
|
||||
id = "2",
|
||||
name = "Bob Williams",
|
||||
phone = "+1 555-0201",
|
||||
last_message = "Thanks for the help yesterday!",
|
||||
time = "1:15 PM",
|
||||
unread = 0,
|
||||
messages = {
|
||||
{sender = "them", text = "Hey, can you help me with something?", time = "Yesterday"},
|
||||
{sender = "me", text = "Sure, what do you need?", time = "Yesterday"},
|
||||
{sender = "them", text = "I need help moving some furniture", time = "Yesterday"},
|
||||
{sender = "me", text = "No problem, I'll be there at 2", time = "Yesterday"},
|
||||
{sender = "them", text = "Thanks for the help yesterday!", time = "1:15 PM"},
|
||||
}
|
||||
},
|
||||
{
|
||||
id = "3",
|
||||
name = "Carol Davis",
|
||||
phone = "+1 555-0301",
|
||||
last_message = "The meeting has been rescheduled to Friday",
|
||||
time = "Yesterday",
|
||||
unread = 0,
|
||||
messages = {
|
||||
{sender = "them", text = "Hi, are you free for a meeting tomorrow?", time = "Monday"},
|
||||
{sender = "me", text = "Let me check my calendar", time = "Monday"},
|
||||
{sender = "me", text = "Yes, I'm free at 3pm", time = "Monday"},
|
||||
{sender = "them", text = "The meeting has been rescheduled to Friday", time = "Yesterday"},
|
||||
}
|
||||
},
|
||||
{
|
||||
id = "4",
|
||||
name = "David Brown",
|
||||
phone = "+1 555-0401",
|
||||
last_message = "Can you send me the files?",
|
||||
time = "Yesterday",
|
||||
unread = 1,
|
||||
messages = {
|
||||
{sender = "them", text = "Hey, do you have the project files?", time = "Yesterday"},
|
||||
{sender = "me", text = "Which ones?", time = "Yesterday"},
|
||||
{sender = "them", text = "Can you send me the files?", time = "Yesterday"},
|
||||
}
|
||||
},
|
||||
{
|
||||
id = "5",
|
||||
name = "Emma Wilson",
|
||||
phone = "+1 555-0501",
|
||||
last_message = "See you at the coffee shop!",
|
||||
time = "Mon",
|
||||
unread = 0,
|
||||
messages = {
|
||||
{sender = "me", text = "Want to grab coffee later?", time = "Mon"},
|
||||
{sender = "them", text = "Sure! What time?", time = "Mon"},
|
||||
{sender = "me", text = "How about 4pm at the usual place?", time = "Mon"},
|
||||
{sender = "them", text = "See you at the coffee shop!", time = "Mon"},
|
||||
}
|
||||
},
|
||||
{
|
||||
id = "6",
|
||||
name = "Frank Miller",
|
||||
phone = "+1 555-0601",
|
||||
last_message = "Great game last night!",
|
||||
time = "Sun",
|
||||
unread = 0,
|
||||
messages = {
|
||||
{sender = "them", text = "Did you watch the game?", time = "Sun"},
|
||||
{sender = "me", text = "Yes! It was amazing!", time = "Sun"},
|
||||
{sender = "them", text = "Great game last night!", time = "Sun"},
|
||||
}
|
||||
},
|
||||
{
|
||||
id = "7",
|
||||
name = "Grace Lee",
|
||||
phone = "+1 555-0701",
|
||||
last_message = "Happy birthday! :)",
|
||||
time = "Sat",
|
||||
unread = 0,
|
||||
messages = {
|
||||
{sender = "them", text = "Happy birthday! :)", time = "Sat"},
|
||||
{sender = "me", text = "Thank you so much! :)", time = "Sat"},
|
||||
}
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- Initialize messages app
|
||||
function initMessages(doc)
|
||||
print("[Messages] Initializing...")
|
||||
messages_doc = doc
|
||||
initConversationsData()
|
||||
renderConversations()
|
||||
end
|
||||
|
||||
-- Render conversation list
|
||||
function renderConversations()
|
||||
if not messages_doc then return end
|
||||
|
||||
local container = messages_doc:GetElementById("conversations-list")
|
||||
if not container then return end
|
||||
|
||||
local html = ""
|
||||
for _, conv in ipairs(conversations) do
|
||||
local color = getAvatarColor(conv.name)
|
||||
local initial = conv.name:sub(1, 1):upper()
|
||||
|
||||
local unread_badge = ""
|
||||
if conv.unread > 0 then
|
||||
unread_badge = [[<div class="conversation-unread">]] .. conv.unread .. [[</div>]]
|
||||
end
|
||||
|
||||
html = html .. [[
|
||||
<div class="conversation-item" onclick="openConversation(']] .. conv.id .. [[')">
|
||||
<div class="conversation-avatar" style="background-color: ]] .. color .. [[;">]] .. initial .. [[</div>
|
||||
<div class="conversation-content">
|
||||
<div class="conversation-header">
|
||||
<span class="conversation-name">]] .. conv.name .. [[</span>
|
||||
<span class="conversation-time">]] .. conv.time .. [[</span>
|
||||
</div>
|
||||
<div class="conversation-preview">]] .. conv.last_message .. [[</div>
|
||||
</div>
|
||||
]] .. unread_badge .. [[
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
|
||||
container.inner_rml = html
|
||||
end
|
||||
|
||||
-- Open a conversation
|
||||
function openConversation(conv_id)
|
||||
print("[Messages] Opening conversation: " .. conv_id)
|
||||
|
||||
-- Find conversation
|
||||
for _, conv in ipairs(conversations) do
|
||||
if conv.id == conv_id then
|
||||
current_conversation = conv
|
||||
current_messages = conv.messages
|
||||
conv.unread = 0 -- Mark as read
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if current_conversation then
|
||||
-- Store for chat screen
|
||||
if mosis and mosis.state then
|
||||
mosis.state.set("current_chat", {
|
||||
id = current_conversation.id,
|
||||
name = current_conversation.name,
|
||||
phone = current_conversation.phone
|
||||
})
|
||||
end
|
||||
|
||||
-- Navigate to chat
|
||||
if navigateTo then
|
||||
navigateTo("chat")
|
||||
else
|
||||
-- Inline chat view
|
||||
showChatInline()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Show chat inline
|
||||
function showChatInline()
|
||||
if not messages_doc then return end
|
||||
|
||||
local list = messages_doc:GetElementById("conversations-container")
|
||||
local chat = messages_doc:GetElementById("chat-container")
|
||||
|
||||
if list and chat then
|
||||
list.style.display = "none"
|
||||
chat.style.display = "flex"
|
||||
renderChat()
|
||||
end
|
||||
end
|
||||
|
||||
-- Hide chat and return to list
|
||||
function hideChat()
|
||||
if not messages_doc then return end
|
||||
|
||||
local list = messages_doc:GetElementById("conversations-container")
|
||||
local chat = messages_doc:GetElementById("chat-container")
|
||||
|
||||
if list and chat then
|
||||
chat.style.display = "none"
|
||||
list.style.display = "flex"
|
||||
renderConversations() -- Refresh to update unread counts
|
||||
end
|
||||
|
||||
current_conversation = nil
|
||||
end
|
||||
|
||||
-- Render chat messages
|
||||
function renderChat()
|
||||
if not messages_doc or not current_conversation then return end
|
||||
|
||||
-- Update header
|
||||
local name_el = messages_doc:GetElementById("chat-name")
|
||||
local avatar_el = messages_doc:GetElementById("chat-avatar")
|
||||
|
||||
if name_el then
|
||||
name_el.inner_rml = current_conversation.name
|
||||
end
|
||||
|
||||
if avatar_el then
|
||||
local color = getAvatarColor(current_conversation.name)
|
||||
local initial = current_conversation.name:sub(1, 1):upper()
|
||||
avatar_el.style["background-color"] = color
|
||||
avatar_el.inner_rml = initial
|
||||
end
|
||||
|
||||
-- Render messages
|
||||
local container = messages_doc:GetElementById("chat-messages")
|
||||
if not container then return end
|
||||
|
||||
local html = ""
|
||||
for _, msg in ipairs(current_messages) do
|
||||
local class = msg.sender == "me" and "message-sent" or "message-received"
|
||||
html = html .. [[
|
||||
<div class="message-bubble ]] .. class .. [[">]] .. msg.text .. [[</div>
|
||||
]]
|
||||
end
|
||||
|
||||
container.inner_rml = html
|
||||
|
||||
-- Scroll to bottom
|
||||
-- Note: RmlUi may need specific handling for scroll
|
||||
end
|
||||
|
||||
-- Send a message
|
||||
function sendMessage()
|
||||
if not messages_doc or not current_conversation then return end
|
||||
|
||||
local input = messages_doc:GetElementById("message-input")
|
||||
if not input then return end
|
||||
|
||||
local text = input.value or ""
|
||||
if text == "" then return end
|
||||
|
||||
print("[Messages] Sending: " .. text)
|
||||
|
||||
-- Add message to current conversation
|
||||
table.insert(current_messages, {
|
||||
sender = "me",
|
||||
text = text,
|
||||
time = "Just now"
|
||||
})
|
||||
|
||||
-- Update conversation preview
|
||||
current_conversation.last_message = text
|
||||
current_conversation.time = "Just now"
|
||||
|
||||
-- Clear input
|
||||
input.value = ""
|
||||
|
||||
-- Re-render chat
|
||||
renderChat()
|
||||
|
||||
-- Simulate reply after delay
|
||||
if setTimeout then
|
||||
setTimeout(function()
|
||||
simulateReply()
|
||||
end, 2000 + math.random(1000, 3000))
|
||||
end
|
||||
end
|
||||
|
||||
-- Simulate a reply
|
||||
function simulateReply()
|
||||
if not current_conversation then return end
|
||||
|
||||
local replies = {
|
||||
"That's great!",
|
||||
"I see",
|
||||
"Sounds good!",
|
||||
"Let me think about it",
|
||||
"Sure thing!",
|
||||
"OK!",
|
||||
"Thanks!",
|
||||
"Got it",
|
||||
"Nice!",
|
||||
"Interesting..."
|
||||
}
|
||||
|
||||
local reply = replies[math.random(#replies)]
|
||||
|
||||
table.insert(current_messages, {
|
||||
sender = "them",
|
||||
text = reply,
|
||||
time = "Just now"
|
||||
})
|
||||
|
||||
current_conversation.last_message = reply
|
||||
current_conversation.time = "Just now"
|
||||
|
||||
renderChat()
|
||||
end
|
||||
|
||||
-- Handle input keypress (for Enter to send)
|
||||
function onMessageKeypress(event)
|
||||
if event.key == "Return" or event.key == "Enter" then
|
||||
sendMessage()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Start new conversation
|
||||
function newConversation()
|
||||
print("[Messages] New conversation")
|
||||
if showToast then
|
||||
showToast("New message")
|
||||
end
|
||||
end
|
||||
|
||||
-- Search conversations
|
||||
function searchConversations(query)
|
||||
print("[Messages] Searching: " .. query)
|
||||
-- TODO: Implement search filtering
|
||||
end
|
||||
|
||||
-- Delete conversation
|
||||
function deleteConversation(conv_id)
|
||||
print("[Messages] Deleting conversation: " .. conv_id)
|
||||
|
||||
for i, conv in ipairs(conversations) do
|
||||
if conv.id == conv_id then
|
||||
table.remove(conversations, i)
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
renderConversations()
|
||||
|
||||
if showToast then
|
||||
showToast("Conversation deleted")
|
||||
end
|
||||
end
|
||||
|
||||
-- Call contact from chat
|
||||
function callFromChat()
|
||||
if not current_conversation then return end
|
||||
|
||||
print("[Messages] Calling from chat: " .. current_conversation.name)
|
||||
|
||||
if mosis and mosis.state then
|
||||
mosis.state.set("current_call", {
|
||||
number = current_conversation.phone,
|
||||
name = current_conversation.name
|
||||
})
|
||||
end
|
||||
|
||||
if navigateTo then
|
||||
navigateTo("calling")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Calling " .. current_conversation.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
299
base-apps/com.mosis.messages/messages.rml
Normal file
299
base-apps/com.mosis.messages/messages.rml
Normal file
@@ -0,0 +1,299 @@
|
||||
<rml>
|
||||
<head>
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
<script src="messages.lua"></script>
|
||||
<title>Messages</title>
|
||||
<style>
|
||||
.conversations-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.conversation-item:active {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.conversation-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 28px;
|
||||
margin-right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.conversation-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.conversation-name {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.conversation-time {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.conversation-preview {
|
||||
font-size: 16px;
|
||||
color: #B3B3B3;
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conversation-unread {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background-color: #BB86FC;
|
||||
color: #000000;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Chat View */
|
||||
#chat-container {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background-color: #1E1E1E;
|
||||
}
|
||||
|
||||
.chat-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.chat-header-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-header-name {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.chat-header-status {
|
||||
font-size: 14px;
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 75%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 18px;
|
||||
font-size: 18px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message-sent {
|
||||
align-self: flex-end;
|
||||
background-color: #BB86FC;
|
||||
color: #000000;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message-received {
|
||||
align-self: flex-start;
|
||||
background-color: #2D2D2D;
|
||||
color: #FFFFFF;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background-color: #1E1E1E;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
padding: 12px 18px;
|
||||
background-color: #2D2D2D;
|
||||
border-radius: 24px;
|
||||
color: #FFFFFF;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.chat-input:hover {
|
||||
background-color: #3D3D3D;
|
||||
}
|
||||
|
||||
.chat-input:focus {
|
||||
background-color: #353535;
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 28px;
|
||||
background-color: #BB86FC;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-send-btn:hover {
|
||||
background-color: #9C64FC;
|
||||
}
|
||||
|
||||
.chat-send-btn:active {
|
||||
background-color: #7C44DC;
|
||||
}
|
||||
|
||||
.chat-send-btn img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="app-screen" onload="initLayout(document); initMessages(document)" data-model="messages">
|
||||
<!-- System Status Bar -->
|
||||
<div class="system-status-bar">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversations List Container -->
|
||||
<div id="conversations-container" style="display: flex; flex-direction: column; flex: 1;">
|
||||
<!-- App Bar -->
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-back" onclick="goBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<span class="app-bar-title">Messages</span>
|
||||
<div class="app-bar-actions">
|
||||
<div class="app-bar-action">
|
||||
<img src="../../icons/search.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversations List -->
|
||||
<div class="app-content with-nav">
|
||||
<div class="conversations-list" id="conversations-list">
|
||||
<!-- Populated by Lua -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAB -->
|
||||
<div class="btn-fab" onclick="newConversation()">
|
||||
<img src="../../icons/add.tga" style="width: 32px; height: 32px;"/>
|
||||
</div>
|
||||
|
||||
<!-- System Navigation Bar -->
|
||||
<div class="system-nav-bar">
|
||||
<div class="system-nav-btn" onclick="onBackPressed()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<div class="system-nav-home" onclick="onHomePressed()"></div>
|
||||
<div class="system-nav-btn" onclick="onRecentPressed()">
|
||||
<img src="../../icons/menu.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Container -->
|
||||
<div id="chat-container">
|
||||
<!-- Chat Header -->
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-back" onclick="hideChat()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<div class="chat-avatar" id="chat-avatar" style="background-color: #4CAF50;">J</div>
|
||||
<div class="chat-header-info">
|
||||
<div class="chat-header-name" id="chat-name">Contact</div>
|
||||
<div class="chat-header-status">Online</div>
|
||||
</div>
|
||||
<div class="btn-icon" onclick="callFromChat()">
|
||||
<img src="../../icons/phone.tga" style="width: 32px; height: 32px;"/>
|
||||
</div>
|
||||
<div class="btn-icon">
|
||||
<img src="../../icons/more.tga" style="width: 32px; height: 32px;"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
<!-- Populated by Lua -->
|
||||
</div>
|
||||
|
||||
<!-- Input Bar -->
|
||||
<div class="chat-input-bar">
|
||||
<div class="btn-icon" style="width: 48px; height: 48px;">
|
||||
<img src="../../icons/add.tga" style="width: 28px; height: 28px;"/>
|
||||
</div>
|
||||
<input class="chat-input" type="text" placeholder="Type a message..." id="message-input"/>
|
||||
<div class="chat-send-btn" onclick="sendMessage()">
|
||||
<img src="../../icons/send.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</rml>
|
||||
18
base-apps/com.mosis.music/manifest.json
Normal file
18
base-apps/com.mosis.music/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "com.mosis.music",
|
||||
"name": "Music",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "music.rml",
|
||||
"icon": "../../icons/music.tga",
|
||||
"description": "Music player application",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [
|
||||
"storage"
|
||||
],
|
||||
"min_api_version": 1
|
||||
}
|
||||
388
base-apps/com.mosis.music/music.lua
Normal file
388
base-apps/com.mosis.music/music.lua
Normal file
@@ -0,0 +1,388 @@
|
||||
-- music.lua - Music player functionality
|
||||
-- Handles playback, playlists, library, and now playing
|
||||
|
||||
local music_doc = nil
|
||||
|
||||
-- Player state
|
||||
local player_state = {
|
||||
is_playing = false,
|
||||
is_shuffled = false,
|
||||
repeat_mode = "off", -- off, all, one
|
||||
current_time = 0,
|
||||
duration = 234, -- 3:54
|
||||
volume = 80
|
||||
}
|
||||
|
||||
-- Current track
|
||||
local current_track = {
|
||||
id = "1",
|
||||
title = "Midnight City",
|
||||
artist = "M83",
|
||||
album = "Hurry Up, We're Dreaming",
|
||||
duration = 234,
|
||||
art_color = "#667eea"
|
||||
}
|
||||
|
||||
-- Playlists
|
||||
local playlists = {
|
||||
{id = "liked", name = "Liked Songs", count = 127, color = "#dc2626"},
|
||||
{id = "daily1", name = "Daily Mix 1", count = 50, color = "#667eea"},
|
||||
{id = "release", name = "Release Radar", count = 30, color = "#16a34a"},
|
||||
{id = "chill", name = "Chill Vibes", count = 45, color = "#f093fb"},
|
||||
{id = "workout", name = "Workout Mix", count = 35, color = "#2563eb"},
|
||||
{id = "focus", name = "Focus Flow", count = 40, color = "#4facfe"}
|
||||
}
|
||||
|
||||
-- Recently played
|
||||
local recently_played = {
|
||||
{id = "pop", name = "Pop Hits", type = "Playlist", color = "#43e97b"},
|
||||
{id = "electronic", name = "Electronic", type = "Playlist", color = "#fa709a"},
|
||||
{id = "jazz", name = "Jazz Classics", type = "Playlist", color = "#667eea"},
|
||||
{id = "rock", name = "Rock Legends", type = "Playlist", color = "#f093fb"}
|
||||
}
|
||||
|
||||
-- Song queue
|
||||
local queue = {
|
||||
{id = "1", title = "Midnight City", artist = "M83", duration = 234},
|
||||
{id = "2", title = "Intro", artist = "The xx", duration = 128},
|
||||
{id = "3", title = "Retrograde", artist = "James Blake", duration = 233},
|
||||
{id = "4", title = "Tame Impala", artist = "The Less I Know The Better", duration = 218},
|
||||
{id = "5", title = "Redbone", artist = "Childish Gambino", duration = 327}
|
||||
}
|
||||
|
||||
local current_queue_index = 1
|
||||
local timer_id = nil
|
||||
|
||||
-- Initialize music app
|
||||
function initMusic(doc)
|
||||
print("[Music] Initializing...")
|
||||
music_doc = doc
|
||||
updateNowPlaying()
|
||||
updateMiniPlayer()
|
||||
renderPlaylists()
|
||||
renderRecentlyPlayed()
|
||||
end
|
||||
|
||||
-- Format time (seconds to mm:ss)
|
||||
local function formatTime(seconds)
|
||||
local mins = math.floor(seconds / 60)
|
||||
local secs = seconds % 60
|
||||
return string.format("%d:%02d", mins, secs)
|
||||
end
|
||||
|
||||
-- Update now playing display
|
||||
function updateNowPlaying()
|
||||
if not music_doc then return end
|
||||
|
||||
local title = music_doc:GetElementById("now-playing-title")
|
||||
local artist = music_doc:GetElementById("now-playing-artist")
|
||||
|
||||
if title then
|
||||
title.inner_rml = current_track.title
|
||||
end
|
||||
if artist then
|
||||
artist.inner_rml = current_track.artist
|
||||
end
|
||||
end
|
||||
|
||||
-- Update mini player
|
||||
function updateMiniPlayer()
|
||||
if not music_doc then return end
|
||||
|
||||
local title = music_doc:GetElementById("mini-player-title")
|
||||
local artist = music_doc:GetElementById("mini-player-artist")
|
||||
local art = music_doc:GetElementById("mini-player-art")
|
||||
local play_btn = music_doc:GetElementById("mini-play-btn")
|
||||
|
||||
if title then
|
||||
title.inner_rml = current_track.title
|
||||
end
|
||||
if artist then
|
||||
artist.inner_rml = current_track.artist
|
||||
end
|
||||
if art then
|
||||
art.style["background-color"] = current_track.art_color
|
||||
art.inner_rml = current_track.title:sub(1,1):upper()
|
||||
end
|
||||
if play_btn then
|
||||
local icon = player_state.is_playing and "pause.tga" or "play.tga"
|
||||
play_btn.inner_rml = [[<img src="../../icons/]] .. icon .. [[" style="width: 28px; height: 28px;"/>]]
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle play/pause
|
||||
function togglePlay()
|
||||
player_state.is_playing = not player_state.is_playing
|
||||
print("[Music] " .. (player_state.is_playing and "Playing" or "Paused"))
|
||||
|
||||
if player_state.is_playing then
|
||||
startPlaybackTimer()
|
||||
else
|
||||
stopPlaybackTimer()
|
||||
end
|
||||
|
||||
updateMiniPlayer()
|
||||
updatePlayButton()
|
||||
end
|
||||
|
||||
-- Start playback timer
|
||||
function startPlaybackTimer()
|
||||
if setInterval then
|
||||
timer_id = setInterval(function()
|
||||
if player_state.is_playing then
|
||||
player_state.current_time = player_state.current_time + 1
|
||||
if player_state.current_time >= current_track.duration then
|
||||
nextTrack()
|
||||
end
|
||||
updateProgress()
|
||||
end
|
||||
end, 1000)
|
||||
end
|
||||
end
|
||||
|
||||
-- Stop playback timer
|
||||
function stopPlaybackTimer()
|
||||
if timer_id and clearInterval then
|
||||
clearInterval(timer_id)
|
||||
timer_id = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Update play button
|
||||
function updatePlayButton()
|
||||
if not music_doc then return end
|
||||
|
||||
local btn = music_doc:GetElementById("play-btn")
|
||||
if btn then
|
||||
local icon = player_state.is_playing and "pause.tga" or "play.tga"
|
||||
btn.inner_rml = [[<img src="../../icons/]] .. icon .. [[" style="width: 48px; height: 48px;"/>]]
|
||||
end
|
||||
end
|
||||
|
||||
-- Update progress display
|
||||
function updateProgress()
|
||||
if not music_doc then return end
|
||||
|
||||
local current = music_doc:GetElementById("current-time")
|
||||
local total = music_doc:GetElementById("total-time")
|
||||
local progress = music_doc:GetElementById("progress-bar")
|
||||
|
||||
if current then
|
||||
current.inner_rml = formatTime(player_state.current_time)
|
||||
end
|
||||
if total then
|
||||
total.inner_rml = formatTime(current_track.duration)
|
||||
end
|
||||
if progress then
|
||||
local percent = (player_state.current_time / current_track.duration) * 100
|
||||
progress.style.width = percent .. "%"
|
||||
end
|
||||
end
|
||||
|
||||
-- Next track
|
||||
function nextTrack()
|
||||
print("[Music] Next track")
|
||||
|
||||
if player_state.is_shuffled then
|
||||
current_queue_index = math.random(1, #queue)
|
||||
else
|
||||
current_queue_index = current_queue_index + 1
|
||||
if current_queue_index > #queue then
|
||||
if player_state.repeat_mode == "all" then
|
||||
current_queue_index = 1
|
||||
else
|
||||
current_queue_index = #queue
|
||||
player_state.is_playing = false
|
||||
stopPlaybackTimer()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
loadTrack(queue[current_queue_index])
|
||||
end
|
||||
|
||||
-- Previous track
|
||||
function previousTrack()
|
||||
print("[Music] Previous track")
|
||||
|
||||
if player_state.current_time > 3 then
|
||||
-- Restart current track
|
||||
player_state.current_time = 0
|
||||
else
|
||||
current_queue_index = current_queue_index - 1
|
||||
if current_queue_index < 1 then
|
||||
current_queue_index = 1
|
||||
end
|
||||
loadTrack(queue[current_queue_index])
|
||||
end
|
||||
|
||||
updateProgress()
|
||||
end
|
||||
|
||||
-- Load a track
|
||||
function loadTrack(track)
|
||||
current_track.id = track.id
|
||||
current_track.title = track.title
|
||||
current_track.artist = track.artist
|
||||
current_track.duration = track.duration
|
||||
player_state.current_time = 0
|
||||
|
||||
updateNowPlaying()
|
||||
updateMiniPlayer()
|
||||
updateProgress()
|
||||
|
||||
print("[Music] Now playing: " .. track.title .. " - " .. track.artist)
|
||||
|
||||
if showToast then
|
||||
showToast("Now playing: " .. track.title)
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle shuffle
|
||||
function toggleShuffle()
|
||||
player_state.is_shuffled = not player_state.is_shuffled
|
||||
print("[Music] Shuffle: " .. tostring(player_state.is_shuffled))
|
||||
|
||||
local btn = music_doc:GetElementById("shuffle-btn")
|
||||
if btn then
|
||||
if player_state.is_shuffled then
|
||||
btn:SetClass("active", true)
|
||||
else
|
||||
btn:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
|
||||
if showToast then
|
||||
showToast(player_state.is_shuffled and "Shuffle on" or "Shuffle off")
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle repeat
|
||||
function toggleRepeat()
|
||||
if player_state.repeat_mode == "off" then
|
||||
player_state.repeat_mode = "all"
|
||||
elseif player_state.repeat_mode == "all" then
|
||||
player_state.repeat_mode = "one"
|
||||
else
|
||||
player_state.repeat_mode = "off"
|
||||
end
|
||||
|
||||
print("[Music] Repeat: " .. player_state.repeat_mode)
|
||||
|
||||
local btn = music_doc:GetElementById("repeat-btn")
|
||||
if btn then
|
||||
if player_state.repeat_mode ~= "off" then
|
||||
btn:SetClass("active", true)
|
||||
else
|
||||
btn:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
|
||||
if showToast then
|
||||
local msg = "Repeat: " .. player_state.repeat_mode
|
||||
showToast(msg)
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle like
|
||||
function toggleLike()
|
||||
print("[Music] Toggle like")
|
||||
if showToast then
|
||||
showToast("Added to Liked Songs")
|
||||
end
|
||||
end
|
||||
|
||||
-- Render playlists
|
||||
function renderPlaylists()
|
||||
if not music_doc then return end
|
||||
|
||||
local container = music_doc:GetElementById("quick-access")
|
||||
if not container then return end
|
||||
|
||||
local html = ""
|
||||
for i, pl in ipairs(playlists) do
|
||||
if i <= 6 then
|
||||
local initial = pl.name:sub(1,1):upper()
|
||||
html = html .. [[
|
||||
<div class="quick-card" onclick="openPlaylist(']] .. pl.id .. [[')">
|
||||
<div class="quick-card-art" style="background-color: ]] .. pl.color .. [[;">]] .. initial .. [[</div>
|
||||
<span class="quick-card-title">]] .. pl.name .. [[</span>
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
end
|
||||
|
||||
container.inner_rml = html
|
||||
end
|
||||
|
||||
-- Render recently played
|
||||
function renderRecentlyPlayed()
|
||||
if not music_doc then return end
|
||||
|
||||
local container = music_doc:GetElementById("recent-row")
|
||||
if not container then return end
|
||||
|
||||
local html = ""
|
||||
for _, item in ipairs(recently_played) do
|
||||
local initial = item.name:sub(1,1):upper()
|
||||
html = html .. [[
|
||||
<div class="recent-item" onclick="openPlaylist(']] .. item.id .. [[')">
|
||||
<div class="recent-art" style="background-color: ]] .. item.color .. [[;">]] .. initial .. [[</div>
|
||||
<div class="recent-title">]] .. item.name .. [[</div>
|
||||
<div class="recent-subtitle">]] .. item.type .. [[</div>
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
|
||||
container.inner_rml = html
|
||||
end
|
||||
|
||||
-- Open playlist
|
||||
function openPlaylist(playlist_id)
|
||||
print("[Music] Opening playlist: " .. playlist_id)
|
||||
if navigateTo then
|
||||
navigateTo("playlist_" .. playlist_id)
|
||||
else
|
||||
if showToast then
|
||||
showToast("Playlist: " .. playlist_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Open now playing
|
||||
function openNowPlaying()
|
||||
print("[Music] Opening now playing...")
|
||||
if navigateTo then
|
||||
navigateTo("now_playing")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open search
|
||||
function openSearch()
|
||||
print("[Music] Opening search...")
|
||||
if navigateTo then
|
||||
navigateTo("music_search")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Search music")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Open library
|
||||
function openLibrary()
|
||||
print("[Music] Opening library...")
|
||||
if navigateTo then
|
||||
navigateTo("music_library")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Your library")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Seek to position (0-1)
|
||||
function seekTo(position)
|
||||
player_state.current_time = math.floor(position * current_track.duration)
|
||||
updateProgress()
|
||||
end
|
||||
@@ -3,36 +3,40 @@
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
<script src="music.lua"></script>
|
||||
<title>Music</title>
|
||||
<style>
|
||||
.music-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #121212;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.music-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding-bottom: 140px;
|
||||
}
|
||||
|
||||
/* Now Playing Mini Bar */
|
||||
.mini-player {
|
||||
position: absolute;
|
||||
bottom: 56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background-color: #282828;
|
||||
border-top: 1px solid #333333;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mini-player:hover {
|
||||
background-color: #333333;
|
||||
}
|
||||
|
||||
.mini-player-art {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background-color: #667eea;
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -46,30 +50,25 @@
|
||||
}
|
||||
|
||||
.mini-player-title {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
color: #FFFFFF;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mini-player-artist {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: #B3B3B3;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.mini-player-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mini-control-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 28px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.mini-control-btn:hover {
|
||||
@@ -77,26 +76,26 @@
|
||||
}
|
||||
|
||||
.mini-control-btn img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Section Headers */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 16px 12px 16px;
|
||||
padding: 20px 16px 12px 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 22px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.section-action {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #B3B3B3;
|
||||
cursor: pointer;
|
||||
@@ -106,7 +105,6 @@
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Recently Played Row */
|
||||
.recent-row {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
@@ -115,43 +113,49 @@
|
||||
}
|
||||
|
||||
.recent-item {
|
||||
min-width: 130px;
|
||||
min-width: 120px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.recent-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.recent-art {
|
||||
width: 130px;
|
||||
height: 130px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
font-size: 36px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.recent-title {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
color: #FFFFFF;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.recent-subtitle {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
/* Quick Access Cards */
|
||||
.quick-access {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.quick-card {
|
||||
width: 48%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #282828;
|
||||
@@ -172,18 +176,17 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-size: 18px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.quick-card-title {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* Playlist Row */
|
||||
.playlist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -218,13 +221,16 @@
|
||||
}
|
||||
|
||||
.playlist-meta {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: #B3B3B3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Bottom Navigation */
|
||||
.music-bottom-nav {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
height: 56px;
|
||||
background-color: #1E1E1E;
|
||||
@@ -241,75 +247,106 @@
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.nav-item img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Color palette for album arts */
|
||||
.bg-gradient-1 { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
.bg-gradient-2 { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
|
||||
.bg-gradient-3 { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); }
|
||||
.bg-gradient-4 { background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); }
|
||||
.bg-gradient-5 { background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); }
|
||||
.bg-gradient-6 { background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); }
|
||||
/* Progress bar for mini player */
|
||||
.progress-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: #404040;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background-color: #1DB954;
|
||||
}
|
||||
|
||||
.bg-gradient-1 { background-color: #667eea; }
|
||||
.bg-gradient-2 { background-color: #f093fb; }
|
||||
.bg-gradient-3 { background-color: #4facfe; }
|
||||
.bg-gradient-4 { background-color: #43e97b; }
|
||||
.bg-gradient-5 { background-color: #fa709a; }
|
||||
.bg-solid-purple { background-color: #7c3aed; }
|
||||
.bg-solid-red { background-color: #dc2626; }
|
||||
.bg-solid-green { background-color: #16a34a; }
|
||||
.bg-solid-blue { background-color: #2563eb; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="music-screen">
|
||||
<body class="app-screen" onload="initLayout(document); initMusic(document)">
|
||||
<!-- System Status Bar -->
|
||||
<div class="system-status-bar">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Bar -->
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-nav btn-icon" onclick="goBack()">
|
||||
<img src="../../icons/back.tga" style="width: 32px; height: 32px;"/>
|
||||
<div class="app-bar-back" onclick="goBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<span class="app-bar-title">Music</span>
|
||||
<div class="btn-icon">
|
||||
<img src="../../icons/search.tga" style="width: 32px; height: 32px;"/>
|
||||
<div class="app-bar-actions">
|
||||
<div class="app-bar-action" onclick="openSearch()">
|
||||
<img src="../../icons/search.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="music-content">
|
||||
<!-- Good Morning Section -->
|
||||
<!-- Good Afternoon Section -->
|
||||
<div class="section-header">
|
||||
<span class="section-title">Good afternoon</span>
|
||||
</div>
|
||||
|
||||
<!-- Quick Access Grid -->
|
||||
<div class="quick-access">
|
||||
<div class="quick-card">
|
||||
<div class="quick-access" id="quick-access">
|
||||
<div class="quick-card" onclick="openPlaylist('liked')">
|
||||
<div class="quick-card-art bg-solid-red">L</div>
|
||||
<span class="quick-card-title">Liked Songs</span>
|
||||
</div>
|
||||
<div class="quick-card">
|
||||
<div class="quick-card" onclick="openPlaylist('daily1')">
|
||||
<div class="quick-card-art bg-gradient-1">D</div>
|
||||
<span class="quick-card-title">Daily Mix 1</span>
|
||||
</div>
|
||||
<div class="quick-card">
|
||||
<div class="quick-card" onclick="openPlaylist('release')">
|
||||
<div class="quick-card-art bg-solid-green">R</div>
|
||||
<span class="quick-card-title">Release Radar</span>
|
||||
</div>
|
||||
<div class="quick-card">
|
||||
<div class="quick-card" onclick="openPlaylist('chill')">
|
||||
<div class="quick-card-art bg-gradient-2">C</div>
|
||||
<span class="quick-card-title">Chill Vibes</span>
|
||||
</div>
|
||||
<div class="quick-card">
|
||||
<div class="quick-card" onclick="openPlaylist('workout')">
|
||||
<div class="quick-card-art bg-solid-blue">W</div>
|
||||
<span class="quick-card-title">Workout Mix</span>
|
||||
</div>
|
||||
<div class="quick-card">
|
||||
<div class="quick-card" onclick="openPlaylist('focus')">
|
||||
<div class="quick-card-art bg-gradient-3">F</div>
|
||||
<span class="quick-card-title">Focus Flow</span>
|
||||
</div>
|
||||
@@ -321,23 +358,23 @@
|
||||
<span class="section-action">SEE ALL</span>
|
||||
</div>
|
||||
|
||||
<div class="recent-row">
|
||||
<div class="recent-item">
|
||||
<div class="recent-row" id="recent-row">
|
||||
<div class="recent-item" onclick="openPlaylist('pop')">
|
||||
<div class="recent-art bg-gradient-4">P</div>
|
||||
<div class="recent-title">Pop Hits</div>
|
||||
<div class="recent-subtitle">Playlist</div>
|
||||
</div>
|
||||
<div class="recent-item">
|
||||
<div class="recent-item" onclick="openPlaylist('electronic')">
|
||||
<div class="recent-art bg-gradient-5">E</div>
|
||||
<div class="recent-title">Electronic</div>
|
||||
<div class="recent-subtitle">Playlist</div>
|
||||
</div>
|
||||
<div class="recent-item">
|
||||
<div class="recent-item" onclick="openPlaylist('jazz')">
|
||||
<div class="recent-art bg-gradient-1">J</div>
|
||||
<div class="recent-title">Jazz Classics</div>
|
||||
<div class="recent-subtitle">Playlist</div>
|
||||
</div>
|
||||
<div class="recent-item">
|
||||
<div class="recent-item" onclick="openPlaylist('rock')">
|
||||
<div class="recent-art bg-gradient-2">R</div>
|
||||
<div class="recent-title">Rock Legends</div>
|
||||
<div class="recent-subtitle">Playlist</div>
|
||||
@@ -350,7 +387,7 @@
|
||||
<span class="section-action">SEE ALL</span>
|
||||
</div>
|
||||
|
||||
<div class="playlist-item">
|
||||
<div class="playlist-item" onclick="openPlaylist('daily1')">
|
||||
<div class="playlist-art bg-gradient-3">1</div>
|
||||
<div class="playlist-info">
|
||||
<div class="playlist-title">Daily Mix 1</div>
|
||||
@@ -358,7 +395,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="playlist-item">
|
||||
<div class="playlist-item" onclick="openPlaylist('daily2')">
|
||||
<div class="playlist-art bg-gradient-4">2</div>
|
||||
<div class="playlist-info">
|
||||
<div class="playlist-title">Daily Mix 2</div>
|
||||
@@ -366,35 +403,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="playlist-item">
|
||||
<div class="playlist-item" onclick="openPlaylist('discover')">
|
||||
<div class="playlist-art bg-gradient-5">D</div>
|
||||
<div class="playlist-info">
|
||||
<div class="playlist-title">Discover Weekly</div>
|
||||
<div class="playlist-meta">Your weekly mixtape</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="playlist-item">
|
||||
<div class="playlist-art bg-solid-green">R</div>
|
||||
<div class="playlist-info">
|
||||
<div class="playlist-title">Release Radar</div>
|
||||
<div class="playlist-meta">New music from artists you follow</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mini Player -->
|
||||
<div class="mini-player">
|
||||
<div class="mini-player-art">M</div>
|
||||
<div class="mini-player-info">
|
||||
<div class="mini-player-title">Midnight City</div>
|
||||
<div class="mini-player-artist">M83</div>
|
||||
<div class="mini-player" onclick="openNowPlaying()">
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar" id="progress-bar"></div>
|
||||
</div>
|
||||
<div class="mini-player-controls">
|
||||
<div class="mini-control-btn">
|
||||
<div class="mini-player-art" id="mini-player-art">M</div>
|
||||
<div class="mini-player-info">
|
||||
<div class="mini-player-title" id="mini-player-title">Midnight City</div>
|
||||
<div class="mini-player-artist" id="mini-player-artist">M83</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 4px;">
|
||||
<div class="mini-control-btn" onclick="toggleLike(); event.stopPropagation();">
|
||||
<img src="../../icons/heart.tga"/>
|
||||
</div>
|
||||
<div class="mini-control-btn">
|
||||
<div class="mini-control-btn" id="mini-play-btn" onclick="togglePlay(); event.stopPropagation();">
|
||||
<img src="../../icons/play.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -406,11 +438,11 @@
|
||||
<img src="../../icons/home.tga"/>
|
||||
<span>Home</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<div class="nav-item" onclick="openSearch()">
|
||||
<img src="../../icons/search.tga"/>
|
||||
<span>Search</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<div class="nav-item" onclick="openLibrary()">
|
||||
<img src="../../icons/library.tga"/>
|
||||
<span>Library</span>
|
||||
</div>
|
||||
BIN
base-apps/com.mosis.sandbox-test.mosis
Normal file
BIN
base-apps/com.mosis.sandbox-test.mosis
Normal file
Binary file not shown.
223
base-apps/com.mosis.sandbox-test/app.lua
Normal file
223
base-apps/com.mosis.sandbox-test/app.lua
Normal file
@@ -0,0 +1,223 @@
|
||||
-- Sandbox Test App
|
||||
-- Tests: timers, JSON, crypto, storage
|
||||
|
||||
local results = {}
|
||||
local logCounter = 0
|
||||
|
||||
-- Helper to get document (use global set by C++)
|
||||
local function getDocument()
|
||||
-- The C++ code sets 'document' global after loading
|
||||
return document
|
||||
end
|
||||
|
||||
local function log(msg)
|
||||
logCounter = logCounter + 1
|
||||
table.insert(results, string.format("[%03d] %s", logCounter, msg))
|
||||
local doc = getDocument()
|
||||
if doc then
|
||||
local el = doc:GetElementById("results")
|
||||
if el then
|
||||
el.inner_rml = table.concat(results, "\n")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Navigation helper
|
||||
function goBack()
|
||||
if navigation and navigation.back then
|
||||
navigation.back()
|
||||
else
|
||||
log("Navigation not available")
|
||||
end
|
||||
end
|
||||
|
||||
local function setStatus(id, status, success)
|
||||
local doc = getDocument()
|
||||
if not doc then
|
||||
print("[LUA] ERROR: document not available!")
|
||||
return
|
||||
end
|
||||
local el = doc:GetElementById(id)
|
||||
if el then
|
||||
if success then
|
||||
el.inner_rml = "✓ " .. status
|
||||
else
|
||||
el.inner_rml = "✗ " .. status
|
||||
end
|
||||
else
|
||||
print("[LUA] ERROR: element not found: " .. id)
|
||||
end
|
||||
end
|
||||
|
||||
-- Timer test
|
||||
function testTimer()
|
||||
print("[LUA] testTimer called!")
|
||||
setStatus("timer-status", "Running...", true)
|
||||
log("Starting timer test...")
|
||||
|
||||
local count = 0
|
||||
local timerId = nil
|
||||
|
||||
timerId = setInterval(function()
|
||||
count = count + 1
|
||||
log("Timer tick: " .. count)
|
||||
|
||||
if count >= 3 then
|
||||
clearInterval(timerId)
|
||||
setStatus("timer-status", "Passed (3 ticks)", true)
|
||||
log("Timer test complete!")
|
||||
end
|
||||
end, 1000)
|
||||
|
||||
log("Timer started with ID: " .. tostring(timerId))
|
||||
end
|
||||
|
||||
-- JSON test
|
||||
function testJSON()
|
||||
log("Starting JSON test...")
|
||||
|
||||
local success = true
|
||||
local msg = ""
|
||||
|
||||
-- Test encode
|
||||
local data = {
|
||||
name = "test",
|
||||
value = 42,
|
||||
nested = { a = 1, b = 2 }
|
||||
}
|
||||
|
||||
local encoded = json.encode(data)
|
||||
if encoded then
|
||||
log("Encoded: " .. encoded)
|
||||
else
|
||||
success = false
|
||||
msg = "encode failed"
|
||||
end
|
||||
|
||||
-- Test decode
|
||||
if success then
|
||||
local decoded = json.decode(encoded)
|
||||
if decoded and decoded.name == "test" and decoded.value == 42 then
|
||||
log("Decoded successfully, name=" .. decoded.name)
|
||||
else
|
||||
success = false
|
||||
msg = "decode failed"
|
||||
end
|
||||
end
|
||||
|
||||
if success then
|
||||
setStatus("json-status", "Passed", true)
|
||||
log("JSON test complete!")
|
||||
else
|
||||
setStatus("json-status", "Failed: " .. msg, false)
|
||||
end
|
||||
end
|
||||
|
||||
-- Crypto test
|
||||
function testCrypto()
|
||||
log("Starting crypto test...")
|
||||
|
||||
local success = true
|
||||
local msg = ""
|
||||
|
||||
-- Test random bytes
|
||||
local bytes = crypto.randomBytes(16)
|
||||
if bytes and #bytes == 16 then
|
||||
log("Random bytes (hex): " .. bytes:gsub(".", function(c)
|
||||
return string.format("%02x", c:byte())
|
||||
end))
|
||||
else
|
||||
success = false
|
||||
msg = "randomBytes failed"
|
||||
end
|
||||
|
||||
-- Test SHA256
|
||||
if success then
|
||||
local hash = crypto.hash("sha256", "hello world")
|
||||
if hash then
|
||||
log("SHA256: " .. hash:sub(1, 32) .. "...")
|
||||
else
|
||||
success = false
|
||||
msg = "sha256 failed"
|
||||
end
|
||||
end
|
||||
|
||||
-- Test HMAC
|
||||
if success then
|
||||
local hmac = crypto.hmac("sha256", "secret", "message")
|
||||
if hmac then
|
||||
log("HMAC: " .. hmac:sub(1, 32) .. "...")
|
||||
else
|
||||
success = false
|
||||
msg = "hmac failed"
|
||||
end
|
||||
end
|
||||
|
||||
if success then
|
||||
setStatus("crypto-status", "Passed", true)
|
||||
log("Crypto test complete!")
|
||||
else
|
||||
setStatus("crypto-status", "Failed: " .. msg, false)
|
||||
end
|
||||
end
|
||||
|
||||
-- Storage test
|
||||
function testStorage()
|
||||
log("Starting storage test...")
|
||||
|
||||
local success = true
|
||||
local msg = ""
|
||||
|
||||
-- Test write (VirtualFS requires /data/, /cache/, /temp/, or /shared/ prefix)
|
||||
local writeOk = fs.write("/data/test.txt", "Hello from sandbox!")
|
||||
if writeOk then
|
||||
log("Write successful")
|
||||
else
|
||||
success = false
|
||||
msg = "write failed"
|
||||
end
|
||||
|
||||
-- Test read
|
||||
if success then
|
||||
local content = fs.read("/data/test.txt")
|
||||
if content == "Hello from sandbox!" then
|
||||
log("Read successful: " .. content)
|
||||
else
|
||||
success = false
|
||||
msg = "read mismatch"
|
||||
end
|
||||
end
|
||||
|
||||
-- Test list
|
||||
if success then
|
||||
local files = fs.list("/data")
|
||||
if files then
|
||||
log("Files in /data: " .. #files)
|
||||
for _, f in ipairs(files) do
|
||||
log(" - " .. f)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Test delete
|
||||
if success then
|
||||
local deleteOk = fs.delete("/data/test.txt")
|
||||
if deleteOk then
|
||||
log("Delete successful")
|
||||
else
|
||||
success = false
|
||||
msg = "delete failed"
|
||||
end
|
||||
end
|
||||
|
||||
if success then
|
||||
setStatus("storage-status", "Passed", true)
|
||||
log("Storage test complete!")
|
||||
else
|
||||
setStatus("storage-status", "Failed: " .. msg, false)
|
||||
end
|
||||
end
|
||||
|
||||
-- Initialize
|
||||
log("Sandbox Test App loaded")
|
||||
log("Lua version: " .. (_VERSION or "unknown"))
|
||||
BIN
base-apps/com.mosis.sandbox-test/icon.tga
LFS
Normal file
BIN
base-apps/com.mosis.sandbox-test/icon.tga
LFS
Normal file
Binary file not shown.
46
base-apps/com.mosis.sandbox-test/main.rml
Normal file
46
base-apps/com.mosis.sandbox-test/main.rml
Normal file
@@ -0,0 +1,46 @@
|
||||
<rml>
|
||||
<head>
|
||||
<title>Sandbox Test</title>
|
||||
<link type="text/rcss" href="styles.rcss"/>
|
||||
<script src="app.lua"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-nav btn-icon" onclick="goHome()">
|
||||
<span class="icon"><</span>
|
||||
</div>
|
||||
<div class="app-bar-title">Sandbox Test</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div class="card-title">Timer Test</div>
|
||||
<div id="timer-status">Not started</div>
|
||||
<button onclick="testTimer()">Start Timer</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">JSON Test</div>
|
||||
<div id="json-status">Not tested</div>
|
||||
<button onclick="testJSON()">Test JSON</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">Crypto Test</div>
|
||||
<div id="crypto-status">Not tested</div>
|
||||
<button onclick="testCrypto()">Test Crypto</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">Storage Test</div>
|
||||
<div id="storage-status">Not tested</div>
|
||||
<button onclick="testStorage()">Test Storage</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">Results</div>
|
||||
<div id="results">Click buttons above to run tests</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</rml>
|
||||
39
base-apps/com.mosis.sandbox-test/main_content.rml
Normal file
39
base-apps/com.mosis.sandbox-test/main_content.rml
Normal file
@@ -0,0 +1,39 @@
|
||||
<!-- Sandbox Test App Content Fragment -->
|
||||
<!-- Styles are in shell.rml -->
|
||||
|
||||
<div class="sandbox-content">
|
||||
<div class="sandbox-header">
|
||||
<span class="sandbox-header-title">Sandbox Test</span>
|
||||
</div>
|
||||
|
||||
<div class="sandbox-body">
|
||||
<div class="sandbox-card">
|
||||
<div class="sandbox-card-title">Timer Test</div>
|
||||
<div id="timer-status" class="sandbox-status">Not started</div>
|
||||
<div class="sandbox-btn" onclick="testSandboxTimer()">Start Timer</div>
|
||||
</div>
|
||||
|
||||
<div class="sandbox-card">
|
||||
<div class="sandbox-card-title">JSON Test</div>
|
||||
<div id="json-status" class="sandbox-status">Not tested</div>
|
||||
<div class="sandbox-btn" onclick="testSandboxJSON()">Test JSON</div>
|
||||
</div>
|
||||
|
||||
<div class="sandbox-card">
|
||||
<div class="sandbox-card-title">Crypto Test</div>
|
||||
<div id="crypto-status" class="sandbox-status">Not tested</div>
|
||||
<div class="sandbox-btn" onclick="testSandboxCrypto()">Test Crypto</div>
|
||||
</div>
|
||||
|
||||
<div class="sandbox-card">
|
||||
<div class="sandbox-card-title">Storage Test</div>
|
||||
<div id="storage-status" class="sandbox-status">Not tested</div>
|
||||
<div class="sandbox-btn" onclick="testSandboxStorage()">Test Storage</div>
|
||||
</div>
|
||||
|
||||
<div class="sandbox-card">
|
||||
<div class="sandbox-card-title">Results</div>
|
||||
<div id="sandbox-results" class="sandbox-results">Click buttons above to run tests</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
18
base-apps/com.mosis.sandbox-test/manifest.json
Normal file
18
base-apps/com.mosis.sandbox-test/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "com.mosis.sandbox-test",
|
||||
"name": "Sandbox Test",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "main.rml",
|
||||
"icon": "icon.tga",
|
||||
"description": "Tests sandbox APIs: timers, storage, JSON, crypto",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"permissions": [
|
||||
"storage",
|
||||
"network"
|
||||
],
|
||||
"min_api_version": 1
|
||||
}
|
||||
97
base-apps/com.mosis.sandbox-test/styles.rcss
Normal file
97
base-apps/com.mosis.sandbox-test/styles.rcss
Normal file
@@ -0,0 +1,97 @@
|
||||
body {
|
||||
font-family: LatoLatin;
|
||||
font-size: 16dp;
|
||||
background-color: #121212;
|
||||
color: #ffffff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 56dp;
|
||||
background-color: #1e1e1e;
|
||||
padding: 0 8dp;
|
||||
}
|
||||
|
||||
.app-bar-nav {
|
||||
width: 40dp;
|
||||
height: 40dp;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 20dp;
|
||||
}
|
||||
|
||||
.app-bar-nav:hover {
|
||||
background-color: #333333;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 24dp;
|
||||
}
|
||||
|
||||
.app-bar-title {
|
||||
font-size: 20dp;
|
||||
font-weight: bold;
|
||||
margin-left: 16dp;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: block;
|
||||
padding: 16dp;
|
||||
width: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: block;
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 12dp;
|
||||
padding: 16dp;
|
||||
margin-bottom: 12dp;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: block;
|
||||
font-size: 18dp;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8dp;
|
||||
color: #bb86fc;
|
||||
}
|
||||
|
||||
.card div {
|
||||
display: block;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
background-color: #bb86fc;
|
||||
color: #000000;
|
||||
border-width: 0;
|
||||
border-radius: 8dp;
|
||||
padding: 12dp 24dp;
|
||||
font-size: 14dp;
|
||||
font-weight: bold;
|
||||
margin-top: 8dp;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #cf9fff;
|
||||
}
|
||||
|
||||
button:active {
|
||||
background-color: #9a67ea;
|
||||
}
|
||||
|
||||
#results {
|
||||
font-family: LatoLatin;
|
||||
font-size: 12dp;
|
||||
background-color: #0d0d0d;
|
||||
padding: 12dp;
|
||||
border-radius: 8dp;
|
||||
white-space: pre-wrap;
|
||||
color: #00ff00;
|
||||
}
|
||||
16
base-apps/com.mosis.settings/manifest.json
Normal file
16
base-apps/com.mosis.settings/manifest.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "com.mosis.settings",
|
||||
"name": "Settings",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "settings.rml",
|
||||
"icon": "../../icons/settings.tga",
|
||||
"description": "System settings and configuration",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [],
|
||||
"min_api_version": 1
|
||||
}
|
||||
288
base-apps/com.mosis.settings/settings.lua
Normal file
288
base-apps/com.mosis.settings/settings.lua
Normal file
@@ -0,0 +1,288 @@
|
||||
-- settings.lua - Settings app functionality
|
||||
-- Handles toggles, navigation, and system settings
|
||||
|
||||
local settings_doc = nil
|
||||
|
||||
-- Settings state
|
||||
local settings_state = {
|
||||
wifi = true,
|
||||
wifi_network = "MosisNetwork",
|
||||
bluetooth = false,
|
||||
airplane_mode = false,
|
||||
location = true,
|
||||
location_mode = "High accuracy",
|
||||
brightness = 80,
|
||||
auto_brightness = true,
|
||||
dark_mode = true,
|
||||
font_size = "Default",
|
||||
sleep_timeout = "5 minutes",
|
||||
sound_volume = 70,
|
||||
ring_volume = 80,
|
||||
vibration = true,
|
||||
dnd = false,
|
||||
battery_percent = 85,
|
||||
battery_status = "Not charging",
|
||||
storage_used = 32,
|
||||
storage_total = 128
|
||||
}
|
||||
|
||||
-- Initialize settings
|
||||
function initSettings(doc)
|
||||
print("[Settings] Initializing...")
|
||||
settings_doc = doc
|
||||
updateAllToggles()
|
||||
updateAllSubtitles()
|
||||
end
|
||||
|
||||
-- Update all toggle states
|
||||
function updateAllToggles()
|
||||
updateToggle("wifi", settings_state.wifi)
|
||||
updateToggle("bluetooth", settings_state.bluetooth)
|
||||
updateToggle("airplane", settings_state.airplane_mode)
|
||||
updateToggle("location", settings_state.location)
|
||||
end
|
||||
|
||||
-- Update a single toggle
|
||||
function updateToggle(name, state)
|
||||
if not settings_doc then return end
|
||||
|
||||
local toggle = settings_doc:GetElementById("toggle-" .. name)
|
||||
if toggle then
|
||||
if state then
|
||||
toggle:SetClass("active", true)
|
||||
else
|
||||
toggle:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Update all subtitles
|
||||
function updateAllSubtitles()
|
||||
if not settings_doc then return end
|
||||
|
||||
-- WiFi
|
||||
local wifi_sub = settings_doc:GetElementById("subtitle-wifi")
|
||||
if wifi_sub then
|
||||
if settings_state.wifi then
|
||||
wifi_sub.inner_rml = "Connected to " .. settings_state.wifi_network
|
||||
else
|
||||
wifi_sub.inner_rml = "Off"
|
||||
end
|
||||
end
|
||||
|
||||
-- Bluetooth
|
||||
local bt_sub = settings_doc:GetElementById("subtitle-bluetooth")
|
||||
if bt_sub then
|
||||
bt_sub.inner_rml = settings_state.bluetooth and "On" or "Off"
|
||||
end
|
||||
|
||||
-- Battery
|
||||
local bat_sub = settings_doc:GetElementById("subtitle-battery")
|
||||
if bat_sub then
|
||||
bat_sub.inner_rml = settings_state.battery_percent .. "% - " .. settings_state.battery_status
|
||||
end
|
||||
|
||||
-- Storage
|
||||
local storage_sub = settings_doc:GetElementById("subtitle-storage")
|
||||
if storage_sub then
|
||||
storage_sub.inner_rml = settings_state.storage_used .. " GB of " .. settings_state.storage_total .. " GB used"
|
||||
end
|
||||
|
||||
-- Location
|
||||
local loc_sub = settings_doc:GetElementById("subtitle-location")
|
||||
if loc_sub then
|
||||
if settings_state.location then
|
||||
loc_sub.inner_rml = "On - " .. settings_state.location_mode
|
||||
else
|
||||
loc_sub.inner_rml = "Off"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle WiFi
|
||||
function toggleWifi()
|
||||
settings_state.wifi = not settings_state.wifi
|
||||
print("[Settings] WiFi: " .. tostring(settings_state.wifi))
|
||||
|
||||
updateToggle("wifi", settings_state.wifi)
|
||||
updateAllSubtitles()
|
||||
|
||||
if showToast then
|
||||
showToast(settings_state.wifi and "WiFi enabled" or "WiFi disabled")
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle Bluetooth
|
||||
function toggleBluetooth()
|
||||
settings_state.bluetooth = not settings_state.bluetooth
|
||||
print("[Settings] Bluetooth: " .. tostring(settings_state.bluetooth))
|
||||
|
||||
updateToggle("bluetooth", settings_state.bluetooth)
|
||||
updateAllSubtitles()
|
||||
|
||||
if showToast then
|
||||
showToast(settings_state.bluetooth and "Bluetooth enabled" or "Bluetooth disabled")
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle Airplane Mode
|
||||
function toggleAirplaneMode()
|
||||
settings_state.airplane_mode = not settings_state.airplane_mode
|
||||
print("[Settings] Airplane mode: " .. tostring(settings_state.airplane_mode))
|
||||
|
||||
if settings_state.airplane_mode then
|
||||
-- Disable wireless when airplane mode is on
|
||||
settings_state.wifi = false
|
||||
settings_state.bluetooth = false
|
||||
updateToggle("wifi", false)
|
||||
updateToggle("bluetooth", false)
|
||||
end
|
||||
|
||||
updateToggle("airplane", settings_state.airplane_mode)
|
||||
updateAllSubtitles()
|
||||
|
||||
if showToast then
|
||||
showToast(settings_state.airplane_mode and "Airplane mode on" or "Airplane mode off")
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle Location
|
||||
function toggleLocation()
|
||||
settings_state.location = not settings_state.location
|
||||
print("[Settings] Location: " .. tostring(settings_state.location))
|
||||
|
||||
updateToggle("location", settings_state.location)
|
||||
updateAllSubtitles()
|
||||
|
||||
if showToast then
|
||||
showToast(settings_state.location and "Location enabled" or "Location disabled")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open WiFi settings
|
||||
function openWifiSettings()
|
||||
print("[Settings] Opening WiFi settings...")
|
||||
if navigateTo then
|
||||
navigateTo("wifi_settings")
|
||||
else
|
||||
if showToast then
|
||||
showToast("WiFi settings")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Bluetooth settings
|
||||
function openBluetoothSettings()
|
||||
print("[Settings] Opening Bluetooth settings...")
|
||||
if showToast then
|
||||
showToast("Bluetooth settings")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Display settings
|
||||
function openDisplaySettings()
|
||||
print("[Settings] Opening Display settings...")
|
||||
if navigateTo then
|
||||
navigateTo("display_settings")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Display settings")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Sound settings
|
||||
function openSoundSettings()
|
||||
print("[Settings] Opening Sound settings...")
|
||||
if showToast then
|
||||
showToast("Sound settings")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Notifications settings
|
||||
function openNotificationsSettings()
|
||||
print("[Settings] Opening Notifications settings...")
|
||||
if showToast then
|
||||
showToast("Notification settings")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Battery settings
|
||||
function openBatterySettings()
|
||||
print("[Settings] Opening Battery settings...")
|
||||
if showToast then
|
||||
showToast("Battery: " .. settings_state.battery_percent .. "%")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Storage settings
|
||||
function openStorageSettings()
|
||||
print("[Settings] Opening Storage settings...")
|
||||
if showToast then
|
||||
local used_percent = math.floor(settings_state.storage_used / settings_state.storage_total * 100)
|
||||
showToast("Storage: " .. used_percent .. "% used")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Lock Screen settings
|
||||
function openLockScreenSettings()
|
||||
print("[Settings] Opening Lock Screen settings...")
|
||||
if showToast then
|
||||
showToast("Lock screen settings")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Privacy settings
|
||||
function openPrivacySettings()
|
||||
print("[Settings] Opening Privacy settings...")
|
||||
if showToast then
|
||||
showToast("Privacy settings")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Location settings
|
||||
function openLocationSettings()
|
||||
print("[Settings] Opening Location settings...")
|
||||
if showToast then
|
||||
showToast("Location settings")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open About Phone
|
||||
function openAboutPhone()
|
||||
print("[Settings] Opening About Phone...")
|
||||
if navigateTo then
|
||||
navigateTo("about_phone")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Mosis Virtual Phone v1.0")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Open User Profile
|
||||
function openUserProfile()
|
||||
print("[Settings] Opening User Profile...")
|
||||
if showToast then
|
||||
showToast("User profile")
|
||||
end
|
||||
end
|
||||
|
||||
-- Search settings
|
||||
function searchSettings(query)
|
||||
print("[Settings] Searching: " .. query)
|
||||
-- TODO: Implement settings search
|
||||
end
|
||||
|
||||
-- Get setting value
|
||||
function getSetting(key)
|
||||
return settings_state[key]
|
||||
end
|
||||
|
||||
-- Set setting value
|
||||
function setSetting(key, value)
|
||||
settings_state[key] = value
|
||||
print("[Settings] Set " .. key .. " = " .. tostring(value))
|
||||
updateAllToggles()
|
||||
updateAllSubtitles()
|
||||
end
|
||||
370
base-apps/com.mosis.settings/settings.rml
Normal file
370
base-apps/com.mosis.settings/settings.rml
Normal file
@@ -0,0 +1,370 @@
|
||||
<rml>
|
||||
<head>
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
<script src="settings.lua"></script>
|
||||
<title>Settings</title>
|
||||
<style>
|
||||
.settings-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
padding: 16px 16px 8px 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #BB86FC;
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
background-color: #1E1E1E;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-item:hover {
|
||||
background-color: #252525;
|
||||
}
|
||||
|
||||
.settings-item:active {
|
||||
background-color: #2A2A2A;
|
||||
}
|
||||
|
||||
.settings-item + .settings-item {
|
||||
border-top: 1px #333333;
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.settings-icon img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
font-size: 18px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.settings-subtitle {
|
||||
font-size: 16px;
|
||||
color: #B3B3B3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.settings-action {
|
||||
font-size: 20px;
|
||||
color: #666666;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
width: 56px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
background-color: #666666;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-toggle:hover {
|
||||
background-color: #777777;
|
||||
}
|
||||
|
||||
.settings-toggle.active {
|
||||
background-color: rgba(187, 134, 252, 0.5);
|
||||
}
|
||||
|
||||
.settings-toggle-thumb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 14px;
|
||||
background-color: #B3B3B3;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
.settings-toggle.active .settings-toggle-thumb {
|
||||
background-color: #BB86FC;
|
||||
left: 26px;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 20px 16px;
|
||||
background-color: #1E1E1E;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-card:hover {
|
||||
background-color: #252525;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 32px;
|
||||
background-color: #BB86FC;
|
||||
margin-right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 16px;
|
||||
color: #B3B3B3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="app-screen" onload="initLayout(document); initSettings(document)">
|
||||
<!-- System Status Bar -->
|
||||
<div class="system-status-bar">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Bar -->
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-back" onclick="goBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<span class="app-bar-title">Settings</span>
|
||||
<div class="app-bar-actions">
|
||||
<div class="app-bar-action">
|
||||
<img src="../../icons/search.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings List -->
|
||||
<div class="app-content with-nav">
|
||||
<div class="settings-list">
|
||||
<!-- User Card -->
|
||||
<div class="user-card" onclick="openUserProfile()">
|
||||
<div class="user-avatar">U</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name">User</div>
|
||||
<div class="user-email">user@mosis.local</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
|
||||
<!-- Network Section -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-header">Network</div>
|
||||
<div class="settings-item" onclick="openWifiSettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Wi-Fi</div>
|
||||
<div class="settings-subtitle" id="subtitle-wifi">Connected to MosisNetwork</div>
|
||||
</div>
|
||||
<div id="toggle-wifi" class="settings-toggle active" onclick="toggleWifi(); event.stopPropagation();">
|
||||
<div class="settings-toggle-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item" onclick="openBluetoothSettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/bluetooth.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Bluetooth</div>
|
||||
<div class="settings-subtitle" id="subtitle-bluetooth">Off</div>
|
||||
</div>
|
||||
<div id="toggle-bluetooth" class="settings-toggle" onclick="toggleBluetooth(); event.stopPropagation();">
|
||||
<div class="settings-toggle-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/airplane.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Airplane Mode</div>
|
||||
</div>
|
||||
<div id="toggle-airplane" class="settings-toggle" onclick="toggleAirplaneMode()">
|
||||
<div class="settings-toggle-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Section -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-header">Device</div>
|
||||
<div class="settings-item" onclick="openDisplaySettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/brightness.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Display</div>
|
||||
<div class="settings-subtitle">Brightness, wallpaper, sleep</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
<div class="settings-item" onclick="openSoundSettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/volume.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Sound</div>
|
||||
<div class="settings-subtitle">Volume, ringtone, vibration</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
<div class="settings-item" onclick="openNotificationsSettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/notifications.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Notifications</div>
|
||||
<div class="settings-subtitle">App notifications, Do not disturb</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
<div class="settings-item" onclick="openBatterySettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Battery</div>
|
||||
<div class="settings-subtitle" id="subtitle-battery">85% - Not charging</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
<div class="settings-item" onclick="openStorageSettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/storage.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Storage</div>
|
||||
<div class="settings-subtitle" id="subtitle-storage">32 GB of 128 GB used</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy Section -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-header">Privacy & Security</div>
|
||||
<div class="settings-item" onclick="openLockScreenSettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/lock.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Lock Screen</div>
|
||||
<div class="settings-subtitle">PIN, pattern, fingerprint</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
<div class="settings-item" onclick="openPrivacySettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/privacy.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Privacy</div>
|
||||
<div class="settings-subtitle">Permissions, account activity</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
<div class="settings-item" onclick="openLocationSettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/location.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Location</div>
|
||||
<div class="settings-subtitle" id="subtitle-location">On - High accuracy</div>
|
||||
</div>
|
||||
<div id="toggle-location" class="settings-toggle active" onclick="toggleLocation(); event.stopPropagation();">
|
||||
<div class="settings-toggle-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About Section -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-header">About</div>
|
||||
<div class="settings-item" onclick="openAboutPhone()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/phone.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">About Phone</div>
|
||||
<div class="settings-subtitle">Mosis Virtual Phone v1.0</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Navigation Bar -->
|
||||
<div class="system-nav-bar">
|
||||
<div class="system-nav-btn" onclick="onBackPressed()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<div class="system-nav-home" onclick="onHomePressed()"></div>
|
||||
<div class="system-nav-btn" onclick="onRecentPressed()">
|
||||
<img src="../../icons/menu.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</rml>
|
||||
19
base-apps/com.mosis.store/manifest.json
Normal file
19
base-apps/com.mosis.store/manifest.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "com.mosis.store",
|
||||
"name": "Mosis Store",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "store.rml",
|
||||
"icon": "../../icons/store.tga",
|
||||
"description": "App store for downloading and installing apps",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [
|
||||
"network",
|
||||
"storage"
|
||||
],
|
||||
"min_api_version": 1
|
||||
}
|
||||
394
base-apps/com.mosis.store/store.lua
Normal file
394
base-apps/com.mosis.store/store.lua
Normal file
@@ -0,0 +1,394 @@
|
||||
-- store.lua - App Store system app logic
|
||||
-- Milestone 10: Device-Side App Management
|
||||
|
||||
-- State
|
||||
local state = {
|
||||
screen = "home", -- home, games, updates, search, detail
|
||||
installed = {}, -- Installed apps from mosis.apps
|
||||
updates = {}, -- Available updates
|
||||
featured = {}, -- Featured apps from store API
|
||||
categories = {}, -- Category list
|
||||
search_query = "", -- Current search
|
||||
selected_app = nil, -- Selected app for detail view
|
||||
is_loading = false,
|
||||
error_message = nil
|
||||
}
|
||||
|
||||
-- Store API configuration
|
||||
local STORE_API = "https://portal.mosis.dev/store"
|
||||
|
||||
-- ============================================================================
|
||||
-- Initialization
|
||||
-- ============================================================================
|
||||
|
||||
function init()
|
||||
print("[Store] Initializing...")
|
||||
|
||||
-- Load installed apps
|
||||
refreshInstalledApps()
|
||||
|
||||
-- Check for updates
|
||||
checkForUpdates()
|
||||
|
||||
-- Fetch featured apps (async)
|
||||
fetchFeaturedApps()
|
||||
end
|
||||
|
||||
function refreshInstalledApps()
|
||||
if mosis and mosis.apps then
|
||||
state.installed = mosis.apps.getInstalled() or {}
|
||||
print("[Store] Loaded " .. #state.installed .. " installed apps")
|
||||
else
|
||||
print("[Store] Warning: mosis.apps API not available")
|
||||
state.installed = {}
|
||||
end
|
||||
end
|
||||
|
||||
function checkForUpdates()
|
||||
if mosis and mosis.apps then
|
||||
state.updates = mosis.apps.checkUpdates() or {}
|
||||
print("[Store] Found " .. #state.updates .. " updates")
|
||||
updateBadge()
|
||||
end
|
||||
end
|
||||
|
||||
function updateBadge()
|
||||
-- Update the updates tab badge
|
||||
local badge = document:GetElementById("updates-badge")
|
||||
if badge then
|
||||
if #state.updates > 0 then
|
||||
badge.inner_rml = tostring(#state.updates)
|
||||
badge.style.display = "block"
|
||||
else
|
||||
badge.style.display = "none"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- API Calls
|
||||
-- ============================================================================
|
||||
|
||||
function fetchFeaturedApps()
|
||||
state.is_loading = true
|
||||
|
||||
-- TODO: Make HTTP request to STORE_API
|
||||
-- For now, use placeholder data
|
||||
state.featured = {
|
||||
{
|
||||
id = "com.mosis.weather",
|
||||
name = "Weather Pro",
|
||||
category = "Weather",
|
||||
rating = 4.8,
|
||||
downloads = 125000,
|
||||
size = 15728640, -- 15 MB
|
||||
description = "Beautiful forecasts for your virtual world",
|
||||
icon = "W",
|
||||
color = "#2196F3"
|
||||
},
|
||||
{
|
||||
id = "com.mosis.notes",
|
||||
name = "Notes",
|
||||
category = "Productivity",
|
||||
rating = 4.7,
|
||||
downloads = 89000,
|
||||
size = 8388608, -- 8 MB
|
||||
description = "Simple note-taking app",
|
||||
icon = "N",
|
||||
color = "#03DAC6"
|
||||
}
|
||||
}
|
||||
|
||||
state.is_loading = false
|
||||
render()
|
||||
end
|
||||
|
||||
function searchApps(query)
|
||||
state.search_query = query
|
||||
state.screen = "search"
|
||||
|
||||
if query == "" then
|
||||
state.screen = "home"
|
||||
render()
|
||||
return
|
||||
end
|
||||
|
||||
state.is_loading = true
|
||||
render()
|
||||
|
||||
-- TODO: Make HTTP request to STORE_API/search
|
||||
-- For now, filter featured apps
|
||||
local results = {}
|
||||
local lower_query = query:lower()
|
||||
for _, app in ipairs(state.featured) do
|
||||
if app.name:lower():find(lower_query) or
|
||||
app.category:lower():find(lower_query) then
|
||||
table.insert(results, app)
|
||||
end
|
||||
end
|
||||
|
||||
state.search_results = results
|
||||
state.is_loading = false
|
||||
render()
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Installation
|
||||
-- ============================================================================
|
||||
|
||||
function installApp(app_id, download_url, signature)
|
||||
print("[Store] Installing: " .. app_id)
|
||||
|
||||
showProgress(app_id)
|
||||
|
||||
if mosis and mosis.apps then
|
||||
mosis.apps.install(download_url or "", signature or "", function(progress)
|
||||
updateProgress(progress)
|
||||
|
||||
if progress.stage == "complete" then
|
||||
hideProgress()
|
||||
showToast("App installed successfully!")
|
||||
refreshInstalledApps()
|
||||
render()
|
||||
elseif progress.stage == "failed" then
|
||||
hideProgress()
|
||||
showError("Installation failed: " .. (progress.error or "Unknown error"))
|
||||
end
|
||||
end)
|
||||
else
|
||||
hideProgress()
|
||||
showError("App installation not available")
|
||||
end
|
||||
end
|
||||
|
||||
function uninstallApp(package_id)
|
||||
print("[Store] Uninstalling: " .. package_id)
|
||||
|
||||
if mosis and mosis.apps then
|
||||
local success = mosis.apps.uninstall(package_id)
|
||||
if success then
|
||||
showToast("App uninstalled")
|
||||
refreshInstalledApps()
|
||||
render()
|
||||
else
|
||||
showError("Failed to uninstall app")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function openApp(package_id)
|
||||
print("[Store] Launching: " .. package_id)
|
||||
|
||||
if mosis and mosis.apps then
|
||||
mosis.apps.launch(package_id)
|
||||
end
|
||||
end
|
||||
|
||||
function updateApp(package_id)
|
||||
print("[Store] Updating: " .. package_id)
|
||||
|
||||
-- Find update info
|
||||
for _, update in ipairs(state.updates) do
|
||||
if update.package_id == package_id then
|
||||
installApp(package_id, update.download_url, update.signature)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
showError("No update available for this app")
|
||||
end
|
||||
|
||||
function updateAllApps()
|
||||
print("[Store] Updating all apps...")
|
||||
|
||||
for _, update in ipairs(state.updates) do
|
||||
-- Queue updates (in a real implementation, this would be sequential)
|
||||
installApp(update.package_id, update.download_url, update.signature)
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- UI Helpers
|
||||
-- ============================================================================
|
||||
|
||||
function isInstalled(package_id)
|
||||
for _, app in ipairs(state.installed) do
|
||||
if app.package_id == package_id then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function hasUpdate(package_id)
|
||||
for _, update in ipairs(state.updates) do
|
||||
if update.package_id == package_id then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function formatSize(bytes)
|
||||
if bytes >= 1048576 then
|
||||
return string.format("%.1f MB", bytes / 1048576)
|
||||
elseif bytes >= 1024 then
|
||||
return string.format("%.0f KB", bytes / 1024)
|
||||
else
|
||||
return bytes .. " B"
|
||||
end
|
||||
end
|
||||
|
||||
function formatDownloads(count)
|
||||
if count >= 1000000 then
|
||||
return string.format("%.1fM", count / 1000000)
|
||||
elseif count >= 1000 then
|
||||
return string.format("%.0fK", count / 1000)
|
||||
else
|
||||
return tostring(count)
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Progress Dialog
|
||||
-- ============================================================================
|
||||
|
||||
function showProgress(app_name)
|
||||
local dialog = document:GetElementById("progress-dialog")
|
||||
if dialog then
|
||||
dialog.style.display = "flex"
|
||||
local title = document:GetElementById("progress-title")
|
||||
if title then
|
||||
title.inner_rml = "Installing " .. (app_name or "App")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function updateProgress(progress)
|
||||
local bar = document:GetElementById("progress-bar")
|
||||
if bar then
|
||||
bar.style.width = (progress.progress * 100) .. "%"
|
||||
end
|
||||
|
||||
local status = document:GetElementById("progress-status")
|
||||
if status then
|
||||
local stage_names = {
|
||||
downloading = "Downloading...",
|
||||
verifying = "Verifying...",
|
||||
extracting = "Extracting...",
|
||||
registering = "Registering...",
|
||||
complete = "Complete!",
|
||||
failed = "Failed"
|
||||
}
|
||||
status.inner_rml = stage_names[progress.stage] or progress.stage
|
||||
end
|
||||
end
|
||||
|
||||
function hideProgress()
|
||||
local dialog = document:GetElementById("progress-dialog")
|
||||
if dialog then
|
||||
dialog.style.display = "none"
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Toast/Error Messages
|
||||
-- ============================================================================
|
||||
|
||||
function showToast(message)
|
||||
local toast = document:GetElementById("toast")
|
||||
if toast then
|
||||
toast.inner_rml = message
|
||||
toast.style.display = "block"
|
||||
-- Auto-hide after 3 seconds (would need timer API)
|
||||
end
|
||||
print("[Store] Toast: " .. message)
|
||||
end
|
||||
|
||||
function showError(message)
|
||||
state.error_message = message
|
||||
local error_el = document:GetElementById("error-dialog")
|
||||
if error_el then
|
||||
local msg = document:GetElementById("error-message")
|
||||
if msg then
|
||||
msg.inner_rml = message
|
||||
end
|
||||
error_el.style.display = "flex"
|
||||
end
|
||||
print("[Store] Error: " .. message)
|
||||
end
|
||||
|
||||
function hideError()
|
||||
state.error_message = nil
|
||||
local error_el = document:GetElementById("error-dialog")
|
||||
if error_el then
|
||||
error_el.style.display = "none"
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Navigation
|
||||
-- ============================================================================
|
||||
|
||||
function showHome()
|
||||
state.screen = "home"
|
||||
setActiveTab("apps")
|
||||
render()
|
||||
end
|
||||
|
||||
function showGames()
|
||||
state.screen = "games"
|
||||
setActiveTab("games")
|
||||
render()
|
||||
end
|
||||
|
||||
function showUpdates()
|
||||
state.screen = "updates"
|
||||
setActiveTab("updates")
|
||||
checkForUpdates()
|
||||
render()
|
||||
end
|
||||
|
||||
function showSearch()
|
||||
state.screen = "search"
|
||||
render()
|
||||
end
|
||||
|
||||
function showAppDetail(app_id)
|
||||
state.screen = "detail"
|
||||
-- Find app in featured or installed
|
||||
for _, app in ipairs(state.featured) do
|
||||
if app.id == app_id then
|
||||
state.selected_app = app
|
||||
break
|
||||
end
|
||||
end
|
||||
render()
|
||||
end
|
||||
|
||||
function setActiveTab(tab)
|
||||
local tabs = {"apps", "games", "updates"}
|
||||
for _, t in ipairs(tabs) do
|
||||
local el = document:GetElementById("nav-" .. t)
|
||||
if el then
|
||||
if t == tab then
|
||||
el:SetClass("active", true)
|
||||
else
|
||||
el:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Rendering
|
||||
-- ============================================================================
|
||||
|
||||
function render()
|
||||
-- The RML is mostly static with dynamic data binding
|
||||
-- In a full implementation, we'd update innerHTML of content areas
|
||||
print("[Store] Rendering screen: " .. state.screen)
|
||||
end
|
||||
|
||||
-- Initialize on load
|
||||
init()
|
||||
@@ -3,24 +3,18 @@
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
<script src="store.lua"></script>
|
||||
<title>Store</title>
|
||||
<style>
|
||||
.store-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #121212;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.store-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Search Bar */
|
||||
.store-search {
|
||||
margin: 16px;
|
||||
background-color: #2D2D2D;
|
||||
@@ -28,11 +22,16 @@
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.store-search:hover {
|
||||
background-color: #3D3D3D;
|
||||
}
|
||||
|
||||
.store-search img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -42,7 +41,6 @@
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
/* Section Headers */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -57,43 +55,46 @@
|
||||
}
|
||||
|
||||
.section-action {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
color: #BB86FC;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Featured Banner */
|
||||
.featured-banner {
|
||||
margin: 0 16px 16px 16px;
|
||||
height: 160px;
|
||||
background: linear-gradient(135deg, #BB86FC 0%, #6200EE 100%);
|
||||
height: 140px;
|
||||
background-color: #7C3AED;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.featured-banner:hover {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.featured-tag {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.featured-title {
|
||||
font-size: 24px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.featured-subtitle {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
color: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
/* App Cards Row */
|
||||
.app-cards-row {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
@@ -102,7 +103,7 @@
|
||||
}
|
||||
|
||||
.app-card {
|
||||
min-width: 140px;
|
||||
min-width: 130px;
|
||||
background-color: #1E1E1E;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
@@ -114,44 +115,35 @@
|
||||
}
|
||||
|
||||
.app-card-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
font-size: 24px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.app-card-name {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.app-card-category {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: #B3B3B3;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.app-card-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
.app-card-rating img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* App List Items */
|
||||
.app-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -186,35 +178,18 @@
|
||||
}
|
||||
|
||||
.app-list-meta {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: #B3B3B3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.app-list-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.app-list-rating img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.app-list-rating span {
|
||||
font-size: 16px;
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
.install-btn {
|
||||
background-color: #BB86FC;
|
||||
color: #000000;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 10px 22px;
|
||||
border-radius: 22px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -225,10 +200,8 @@
|
||||
.install-btn.installed {
|
||||
background-color: transparent;
|
||||
color: #BB86FC;
|
||||
border: 1px solid #BB86FC;
|
||||
}
|
||||
|
||||
/* Category Chips */
|
||||
.category-chips {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
@@ -256,7 +229,6 @@
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* Bottom Nav */
|
||||
.store-bottom-nav {
|
||||
display: flex;
|
||||
height: 56px;
|
||||
@@ -273,21 +245,24 @@
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
.store-nav-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.store-nav-item.active {
|
||||
color: #BB86FC;
|
||||
}
|
||||
|
||||
.store-nav-item img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.store-nav-item span {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Color palette for app icons */
|
||||
.bg-purple { background-color: #BB86FC; }
|
||||
.bg-teal { background-color: #03DAC6; }
|
||||
.bg-orange { background-color: #FF9800; }
|
||||
@@ -298,15 +273,27 @@
|
||||
.bg-indigo { background-color: #3F51B5; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="store-screen">
|
||||
<body class="app-screen" onload="initLayout(document)">
|
||||
<!-- System Status Bar -->
|
||||
<div class="system-status-bar">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Bar -->
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-nav btn-icon" onclick="goBack()">
|
||||
<img src="../../icons/back.tga" style="width: 32px; height: 32px;"/>
|
||||
<div class="app-bar-back" onclick="goBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<span class="app-bar-title">Mosis Store</span>
|
||||
<div class="btn-icon">
|
||||
<img src="../../icons/account.tga" style="width: 32px; height: 32px;"/>
|
||||
<div class="app-bar-actions">
|
||||
<div class="app-bar-action">
|
||||
<img src="../../icons/account.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -331,7 +318,6 @@
|
||||
<div class="category-chip">Games</div>
|
||||
<div class="category-chip">Social</div>
|
||||
<div class="category-chip">Productivity</div>
|
||||
<div class="category-chip">Entertainment</div>
|
||||
<div class="category-chip">Tools</div>
|
||||
</div>
|
||||
|
||||
@@ -374,15 +360,11 @@
|
||||
<span class="section-action">See all</span>
|
||||
</div>
|
||||
|
||||
<!-- App List -->
|
||||
<div class="app-list-item">
|
||||
<div class="app-list-icon bg-purple">S</div>
|
||||
<div class="app-list-info">
|
||||
<div class="app-list-name">Social Hub</div>
|
||||
<div class="app-list-meta">Social • 12 MB</div>
|
||||
<div class="app-list-rating">
|
||||
<span>4.9 • 1.2M downloads</span>
|
||||
</div>
|
||||
<div class="app-list-meta">Social - 12 MB - 4.9</div>
|
||||
</div>
|
||||
<div class="install-btn">Install</div>
|
||||
</div>
|
||||
@@ -391,10 +373,7 @@
|
||||
<div class="app-list-icon bg-red">G</div>
|
||||
<div class="app-list-info">
|
||||
<div class="app-list-name">Games Center</div>
|
||||
<div class="app-list-meta">Games • 45 MB</div>
|
||||
<div class="app-list-rating">
|
||||
<span>4.7 • 890K downloads</span>
|
||||
</div>
|
||||
<div class="app-list-meta">Games - 45 MB - 4.7</div>
|
||||
</div>
|
||||
<div class="install-btn">Install</div>
|
||||
</div>
|
||||
@@ -403,10 +382,7 @@
|
||||
<div class="app-list-icon bg-indigo">F</div>
|
||||
<div class="app-list-info">
|
||||
<div class="app-list-name">File Manager</div>
|
||||
<div class="app-list-meta">Tools • 8 MB</div>
|
||||
<div class="app-list-rating">
|
||||
<span>4.6 • 650K downloads</span>
|
||||
</div>
|
||||
<div class="app-list-meta">Tools - 8 MB - 4.6</div>
|
||||
</div>
|
||||
<div class="install-btn installed">Open</div>
|
||||
</div>
|
||||
@@ -415,58 +391,10 @@
|
||||
<div class="app-list-icon bg-pink">M</div>
|
||||
<div class="app-list-info">
|
||||
<div class="app-list-name">Music Player</div>
|
||||
<div class="app-list-meta">Music • 18 MB</div>
|
||||
<div class="app-list-rating">
|
||||
<span>4.5 • 520K downloads</span>
|
||||
</div>
|
||||
<div class="app-list-meta">Music - 18 MB - 4.5</div>
|
||||
</div>
|
||||
<div class="install-btn">Install</div>
|
||||
</div>
|
||||
|
||||
<div class="app-list-item">
|
||||
<div class="app-list-icon bg-teal">P</div>
|
||||
<div class="app-list-info">
|
||||
<div class="app-list-name">Photo Editor</div>
|
||||
<div class="app-list-meta">Photography • 32 MB</div>
|
||||
<div class="app-list-rating">
|
||||
<span>4.4 • 410K downloads</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="install-btn">Install</div>
|
||||
</div>
|
||||
|
||||
<!-- New Games Section -->
|
||||
<div class="section-header">
|
||||
<span class="section-title">New Games</span>
|
||||
<span class="section-action">See all</span>
|
||||
</div>
|
||||
|
||||
<div class="app-cards-row">
|
||||
<div class="app-card">
|
||||
<div class="app-card-icon bg-red">P</div>
|
||||
<div class="app-card-name">Puzzle Quest</div>
|
||||
<div class="app-card-category">Puzzle</div>
|
||||
<div class="app-card-rating">4.8</div>
|
||||
</div>
|
||||
<div class="app-card">
|
||||
<div class="app-card-icon bg-green">R</div>
|
||||
<div class="app-card-name">Racing VR</div>
|
||||
<div class="app-card-category">Racing</div>
|
||||
<div class="app-card-rating">4.6</div>
|
||||
</div>
|
||||
<div class="app-card">
|
||||
<div class="app-card-icon bg-blue">S</div>
|
||||
<div class="app-card-name">Space Explorer</div>
|
||||
<div class="app-card-category">Adventure</div>
|
||||
<div class="app-card-rating">4.7</div>
|
||||
</div>
|
||||
<div class="app-card">
|
||||
<div class="app-card-icon bg-orange">C</div>
|
||||
<div class="app-card-name">Card Master</div>
|
||||
<div class="app-card-category">Card</div>
|
||||
<div class="app-card-rating">4.5</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
BIN
base-apps/icons/account.tga
LFS
Normal file
BIN
base-apps/icons/account.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/add.tga
LFS
Normal file
BIN
base-apps/icons/add.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/back.tga
LFS
Normal file
BIN
base-apps/icons/back.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/backspace.tga
LFS
Normal file
BIN
base-apps/icons/backspace.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/battery.tga
LFS
Normal file
BIN
base-apps/icons/battery.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/browser.tga
LFS
Normal file
BIN
base-apps/icons/browser.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/calculator.tga
LFS
Normal file
BIN
base-apps/icons/calculator.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/calendar.tga
LFS
Normal file
BIN
base-apps/icons/calendar.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/call_small.tga
LFS
Normal file
BIN
base-apps/icons/call_small.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/camera.tga
LFS
Normal file
BIN
base-apps/icons/camera.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/clock.tga
LFS
Normal file
BIN
base-apps/icons/clock.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/close.tga
LFS
Normal file
BIN
base-apps/icons/close.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/contact_phone.tga
LFS
Normal file
BIN
base-apps/icons/contact_phone.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/contacts.tga
LFS
Normal file
BIN
base-apps/icons/contacts.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/dialpad.tga
LFS
Normal file
BIN
base-apps/icons/dialpad.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/download.tga
LFS
Normal file
BIN
base-apps/icons/download.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/files.tga
LFS
Normal file
BIN
base-apps/icons/files.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/flash.tga
LFS
Normal file
BIN
base-apps/icons/flash.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/forward.tga
LFS
Normal file
BIN
base-apps/icons/forward.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/gallery.tga
LFS
Normal file
BIN
base-apps/icons/gallery.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/game.tga
LFS
Normal file
BIN
base-apps/icons/game.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/heart.tga
LFS
Normal file
BIN
base-apps/icons/heart.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/history.tga
LFS
Normal file
BIN
base-apps/icons/history.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/home.tga
LFS
Normal file
BIN
base-apps/icons/home.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/library.tga
LFS
Normal file
BIN
base-apps/icons/library.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/maps.tga
LFS
Normal file
BIN
base-apps/icons/maps.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/menu.tga
LFS
Normal file
BIN
base-apps/icons/menu.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/message.tga
LFS
Normal file
BIN
base-apps/icons/message.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/more.tga
LFS
Normal file
BIN
base-apps/icons/more.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/music.tga
LFS
Normal file
BIN
base-apps/icons/music.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/notes.tga
LFS
Normal file
BIN
base-apps/icons/notes.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/phone.tga
LFS
Normal file
BIN
base-apps/icons/phone.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/play.tga
LFS
Normal file
BIN
base-apps/icons/play.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/refresh.tga
LFS
Normal file
BIN
base-apps/icons/refresh.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/search.tga
LFS
Normal file
BIN
base-apps/icons/search.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/send.tga
LFS
Normal file
BIN
base-apps/icons/send.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/settings.tga
LFS
Normal file
BIN
base-apps/icons/settings.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/signal.tga
LFS
Normal file
BIN
base-apps/icons/signal.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/store.tga
LFS
Normal file
BIN
base-apps/icons/store.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/switch-camera.tga
LFS
Normal file
BIN
base-apps/icons/switch-camera.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/timer.tga
LFS
Normal file
BIN
base-apps/icons/timer.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/weather.tga
LFS
Normal file
BIN
base-apps/icons/weather.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/wifi.tga
LFS
Normal file
BIN
base-apps/icons/wifi.tga
LFS
Normal file
Binary file not shown.
21
base-apps/package.bat
Normal file
21
base-apps/package.bat
Normal file
@@ -0,0 +1,21 @@
|
||||
@echo off
|
||||
REM Package test apps as .mosis files
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
for /d %%d in (*) do (
|
||||
if exist "%%d\manifest.json" (
|
||||
echo Packaging %%d...
|
||||
cd %%d
|
||||
if exist "..\%%d.mosis" del "..\%%d.mosis"
|
||||
tar -a -cf "..\%%d.mosis" *
|
||||
cd ..
|
||||
echo Created %%d.mosis
|
||||
)
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Done! Package files:
|
||||
dir /b *.mosis 2>nul
|
||||
|
||||
endlocal
|
||||
103
base-apps/scripts/layout.lua
Normal file
103
base-apps/scripts/layout.lua
Normal file
@@ -0,0 +1,103 @@
|
||||
-- Layout System for Virtual Smartphone
|
||||
-- Provides reusable UI component helpers
|
||||
-- Requires navigation.lua to be loaded first
|
||||
|
||||
-- Icon paths (relative to assets/)
|
||||
local ICON_PATH = "../../icons/"
|
||||
|
||||
-- Default icons
|
||||
local icons = {
|
||||
back = ICON_PATH .. "back.tga",
|
||||
home = ICON_PATH .. "home.tga",
|
||||
menu = ICON_PATH .. "menu.tga",
|
||||
search = ICON_PATH .. "search.tga",
|
||||
more = ICON_PATH .. "more.tga",
|
||||
close = ICON_PATH .. "close.tga",
|
||||
wifi = ICON_PATH .. "wifi.tga",
|
||||
signal = ICON_PATH .. "signal.tga",
|
||||
battery = ICON_PATH .. "battery.tga"
|
||||
}
|
||||
|
||||
-- Get current time formatted as HH:MM
|
||||
local function getCurrentTime()
|
||||
-- In sandbox, we might not have os.date, use a default
|
||||
if os and os.date then
|
||||
return os.date("%H:%M")
|
||||
end
|
||||
return "12:30"
|
||||
end
|
||||
|
||||
-- Update status bar time
|
||||
function updateStatusTime(doc)
|
||||
local timeEl = doc:GetElementById("status-time")
|
||||
if timeEl then
|
||||
timeEl.inner_rml = getCurrentTime()
|
||||
end
|
||||
end
|
||||
|
||||
-- Initialize status bar with current time
|
||||
-- Call from document onload
|
||||
function initStatusBar(doc)
|
||||
updateStatusTime(doc)
|
||||
|
||||
-- Set up timer to update time every minute if timers are available
|
||||
if setTimeout then
|
||||
local function updateLoop()
|
||||
updateStatusTime(doc)
|
||||
setTimeout(updateLoop, 60000)
|
||||
end
|
||||
setTimeout(updateLoop, 60000)
|
||||
end
|
||||
end
|
||||
|
||||
-- Initialize app bar back button
|
||||
-- Call from document onload
|
||||
function initAppBar(doc)
|
||||
local backBtn = doc:GetElementById("app-bar-back")
|
||||
if backBtn then
|
||||
-- Back button is handled via onclick in RML
|
||||
-- This is for any additional setup
|
||||
end
|
||||
end
|
||||
|
||||
-- Initialize system navigation bar
|
||||
-- Call from document onload
|
||||
function initSystemNav(doc)
|
||||
-- Navigation buttons are handled via onclick in RML
|
||||
-- This is for any additional setup
|
||||
end
|
||||
|
||||
-- Full layout initialization
|
||||
-- Call from document onload: initLayout(document)
|
||||
function initLayout(doc)
|
||||
initStatusBar(doc)
|
||||
initAppBar(doc)
|
||||
initSystemNav(doc)
|
||||
print("Layout initialized")
|
||||
end
|
||||
|
||||
-- Handle back button press (for app bar or system nav)
|
||||
function onBackPressed()
|
||||
if canGoBack and canGoBack() then
|
||||
goBack()
|
||||
else
|
||||
-- If at root, go home
|
||||
if goHome then
|
||||
goHome()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle home button press
|
||||
function onHomePressed()
|
||||
if goHome then
|
||||
goHome()
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle recent apps button press (placeholder)
|
||||
function onRecentPressed()
|
||||
print("Recent apps pressed (not implemented)")
|
||||
end
|
||||
|
||||
print("Layout system loaded")
|
||||
145
base-apps/scripts/navigation.lua
Normal file
145
base-apps/scripts/navigation.lua
Normal file
@@ -0,0 +1,145 @@
|
||||
-- Navigation System for Virtual Smartphone
|
||||
-- Handles screen transitions and state management
|
||||
|
||||
-- Screen registry - maps screen names to RML file paths
|
||||
local screens = {
|
||||
home = "apps/home/home.rml",
|
||||
lock = "apps/home/lock.rml",
|
||||
dialer = "apps/dialer/dialer.rml",
|
||||
calling = "apps/dialer/calling.rml",
|
||||
contacts = "apps/contacts/contacts.rml",
|
||||
contact_detail = "apps/contacts/contact_detail.rml",
|
||||
messages = "apps/messages/messages.rml",
|
||||
chat = "apps/messages/chat.rml",
|
||||
settings = "apps/settings/settings.rml",
|
||||
browser = "apps/browser/browser.rml",
|
||||
store = "apps/store/store.rml",
|
||||
camera = "apps/camera/camera.rml",
|
||||
music = "apps/music/music.rml"
|
||||
}
|
||||
|
||||
-- Use global state to persist across document loads
|
||||
-- Initialize only if not already set
|
||||
if not _G.nav_state then
|
||||
_G.nav_state = {
|
||||
history = {},
|
||||
current_screen = "home",
|
||||
nav_direction = "none" -- "forward", "back", "home", "none"
|
||||
}
|
||||
end
|
||||
|
||||
-- Local references for convenience
|
||||
local history = _G.nav_state.history
|
||||
local function get_current() return _G.nav_state.current_screen end
|
||||
local function set_current(s) _G.nav_state.current_screen = s end
|
||||
local function get_direction() return _G.nav_state.nav_direction end
|
||||
local function set_direction(d) _G.nav_state.nav_direction = d end
|
||||
|
||||
-- Apply animation class based on navigation direction
|
||||
local function applyNavAnimation()
|
||||
local dir = get_direction()
|
||||
print("Applying animation, direction: " .. dir)
|
||||
if dir ~= "none" and document then
|
||||
-- In RmlUi Lua, get body element
|
||||
local body = document.body
|
||||
if body then
|
||||
print("Found body element, setting class nav-" .. dir)
|
||||
-- Set the appropriate animation class
|
||||
if dir == "forward" then
|
||||
body:SetClass("nav-forward", true)
|
||||
elseif dir == "back" then
|
||||
body:SetClass("nav-back", true)
|
||||
elseif dir == "home" then
|
||||
body:SetClass("nav-home", true)
|
||||
else
|
||||
body:SetClass("nav-default", true)
|
||||
end
|
||||
else
|
||||
print("Body element not found!")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Navigate to a screen by name
|
||||
function navigateTo(screen_name)
|
||||
print("navigateTo called with: " .. tostring(screen_name))
|
||||
local path = screens[screen_name]
|
||||
if path then
|
||||
-- Push current screen to history before navigating
|
||||
table.insert(history, get_current())
|
||||
set_current(screen_name)
|
||||
set_direction("forward")
|
||||
|
||||
-- Load the new screen using C++ function
|
||||
local success = loadScreen(path)
|
||||
if success then
|
||||
applyNavAnimation()
|
||||
print("Navigated to: " .. screen_name .. " (history depth: " .. #history .. ")")
|
||||
else
|
||||
-- Restore previous state on failure
|
||||
set_current(table.remove(history))
|
||||
print("Failed to navigate to: " .. screen_name)
|
||||
end
|
||||
return success
|
||||
else
|
||||
print("Unknown screen: " .. screen_name)
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
-- Go back to previous screen
|
||||
function goBack()
|
||||
print("goBack called (history depth: " .. #history .. ")")
|
||||
if #history > 0 then
|
||||
local previous = table.remove(history)
|
||||
local path = screens[previous]
|
||||
if path then
|
||||
set_current(previous)
|
||||
set_direction("back")
|
||||
loadScreen(path)
|
||||
applyNavAnimation()
|
||||
print("Back to: " .. previous)
|
||||
return true
|
||||
end
|
||||
else
|
||||
print("No history to go back to")
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Go to home screen (clear history)
|
||||
function goHome()
|
||||
-- Clear the history table
|
||||
for i = #history, 1, -1 do
|
||||
history[i] = nil
|
||||
end
|
||||
set_current("home")
|
||||
set_direction("home")
|
||||
loadScreen(screens.home)
|
||||
applyNavAnimation()
|
||||
print("Navigated to home")
|
||||
end
|
||||
|
||||
-- Get current screen name
|
||||
function getCurrentScreen()
|
||||
return get_current()
|
||||
end
|
||||
|
||||
-- Check if we can go back
|
||||
function canGoBack()
|
||||
return #history > 0
|
||||
end
|
||||
|
||||
-- Clear navigation history
|
||||
function clearHistory()
|
||||
for i = #history, 1, -1 do
|
||||
history[i] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Get history depth
|
||||
function getHistoryDepth()
|
||||
return #history
|
||||
end
|
||||
|
||||
print("Navigation system initialized (current: " .. get_current() .. ", history: " .. #history .. ")")
|
||||
1485
base-apps/ui/components.rcss
Normal file
1485
base-apps/ui/components.rcss
Normal file
File diff suppressed because it is too large
Load Diff
93
base-apps/ui/html.rcss
Normal file
93
base-apps/ui/html.rcss
Normal file
@@ -0,0 +1,93 @@
|
||||
body, div,
|
||||
h1, h2, h3, h4,
|
||||
h5, h6, p,
|
||||
hr, pre,
|
||||
tabset tabs
|
||||
{
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1
|
||||
{
|
||||
font-size: 2em;
|
||||
margin: .67em 0;
|
||||
}
|
||||
|
||||
h2
|
||||
{
|
||||
font-size: 1.5em;
|
||||
margin: .75em 0;
|
||||
}
|
||||
|
||||
h3
|
||||
{
|
||||
font-size: 1.17em;
|
||||
margin: .83em 0;
|
||||
}
|
||||
|
||||
h4, p
|
||||
{
|
||||
margin: 1.12em 0;
|
||||
}
|
||||
|
||||
h5
|
||||
{
|
||||
font-size: .83em;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
h6
|
||||
{
|
||||
font-size: .75em;
|
||||
margin: 1.67em 0;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4,
|
||||
h5, h6, strong
|
||||
{
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
em
|
||||
{
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
pre
|
||||
{
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
hr
|
||||
{
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
table
|
||||
{
|
||||
box-sizing: border-box;
|
||||
display: table;
|
||||
}
|
||||
tr
|
||||
{
|
||||
box-sizing: border-box;
|
||||
display: table-row;
|
||||
}
|
||||
td
|
||||
{
|
||||
box-sizing: border-box;
|
||||
display: table-cell;
|
||||
}
|
||||
col
|
||||
{
|
||||
box-sizing: border-box;
|
||||
display: table-column;
|
||||
}
|
||||
colgroup
|
||||
{
|
||||
display: table-column-group;
|
||||
}
|
||||
thead, tbody, tfoot
|
||||
{
|
||||
display: table-row-group;
|
||||
}
|
||||
270
base-apps/ui/layout.rcss
Normal file
270
base-apps/ui/layout.rcss
Normal file
@@ -0,0 +1,270 @@
|
||||
/* ==============================================
|
||||
Layout Components: Reusable App Layout Structure
|
||||
System status bar, app bar, navigation bar
|
||||
============================================== */
|
||||
|
||||
/* ============== System Status Bar ============== */
|
||||
/* Top bar showing time, signal, wifi, battery */
|
||||
|
||||
.system-status-bar {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
color: #FFFFFF;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.system-status-bar.bg-surface {
|
||||
background-color: #1E1E1E;
|
||||
}
|
||||
|
||||
.system-status-time {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.system-status-icons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.system-status-icons img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ============== App Bar ============== */
|
||||
/* Title bar with back button and optional actions */
|
||||
|
||||
.app-bar {
|
||||
height: 72px;
|
||||
padding: 0 8px;
|
||||
background-color: #1E1E1E;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
.app-bar.transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.app-bar.primary {
|
||||
background-color: #121212;
|
||||
}
|
||||
|
||||
.app-bar-back {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.app-bar-back:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.app-bar-back:active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.app-bar-back img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-bar-title {
|
||||
flex: 1;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.app-bar-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app-bar-action {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.app-bar-action:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.app-bar-action:active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.app-bar-action img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ============== System Navigation Bar ============== */
|
||||
/* Bottom bar with back, home, and recent buttons */
|
||||
|
||||
.system-nav-bar {
|
||||
height: 56px;
|
||||
background-color: #0A0A0A;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.system-nav-bar.transparent {
|
||||
background-color: rgba(10, 10, 10, 0.9);
|
||||
}
|
||||
|
||||
.system-nav-btn {
|
||||
width: 72px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.system-nav-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.system-nav-btn:active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.system-nav-btn img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Home button - pill shape */
|
||||
.system-nav-home {
|
||||
width: 96px;
|
||||
height: 8px;
|
||||
background-color: #FFFFFF;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.system-nav-home:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.system-nav-home:active {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
/* ============== Screen Layout ============== */
|
||||
/* Standard app screen structure */
|
||||
|
||||
.app-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #121212;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Content padding for nav bar */
|
||||
.app-content.with-nav {
|
||||
padding-bottom: 56px;
|
||||
}
|
||||
|
||||
/* Content padding for dock */
|
||||
.app-content.with-dock {
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
/* ============== Combined Header ============== */
|
||||
/* Status bar + App bar combined */
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #1E1E1E;
|
||||
}
|
||||
|
||||
.app-header.transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.app-header .system-status-bar {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* ============== Notification Badge ============== */
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
background-color: #CF6679;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* ============== Recording Indicator ============== */
|
||||
/* Shown when camera/mic is active */
|
||||
|
||||
.recording-indicator {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #F44336;
|
||||
border-radius: 6px;
|
||||
animation: recording-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes recording-pulse {
|
||||
0%, 100% { opacity: 1.0; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ============== Divider ============== */
|
||||
|
||||
.header-divider {
|
||||
height: 1px;
|
||||
background-color: #333333;
|
||||
}
|
||||
333
base-apps/ui/theme.rcss
Normal file
333
base-apps/ui/theme.rcss
Normal file
@@ -0,0 +1,333 @@
|
||||
/* ==============================================
|
||||
Theme: Material Dark for Virtual Smartphone (VR Optimized)
|
||||
All sizes increased for VR readability and raycast interaction
|
||||
============================================== */
|
||||
|
||||
/* Base body styling */
|
||||
body {
|
||||
font-family: LatoLatin;
|
||||
background-color: #121212;
|
||||
color: #FFFFFF;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
animation: 0.2s cubic-out fade-in;
|
||||
}
|
||||
|
||||
/* ============== Typography (VR-sized) ============== */
|
||||
|
||||
.text-h1 {
|
||||
font-size: 120px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.text-h2 {
|
||||
font-size: 80px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.text-h3 {
|
||||
font-size: 64px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text-h4 {
|
||||
font-size: 48px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text-h5 {
|
||||
font-size: 32px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text-h6 {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-body1 {
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text-body2 {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text-caption {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text-overline {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* ============== Text Colors ============== */
|
||||
|
||||
.text-primary {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
.text-disabled {
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: #BB86FC;
|
||||
}
|
||||
|
||||
.text-accent-secondary {
|
||||
color: #03DAC6;
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: #CF6679;
|
||||
}
|
||||
|
||||
/* ============== Background Colors ============== */
|
||||
|
||||
.bg-primary {
|
||||
background-color: #121212;
|
||||
}
|
||||
|
||||
.bg-surface {
|
||||
background-color: #1E1E1E;
|
||||
}
|
||||
|
||||
.bg-surface-variant {
|
||||
background-color: #2D2D2D;
|
||||
}
|
||||
|
||||
.bg-accent {
|
||||
background-color: #BB86FC;
|
||||
}
|
||||
|
||||
.bg-accent-secondary {
|
||||
background-color: #03DAC6;
|
||||
}
|
||||
|
||||
.bg-error {
|
||||
background-color: #CF6679;
|
||||
}
|
||||
|
||||
/* Hover highlight color - used for interactive element feedback */
|
||||
.bg-hover {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* ============== Spacing Utilities (VR-scaled) ============== */
|
||||
|
||||
.p-0 { padding: 0; }
|
||||
.p-1 { padding: 6px; }
|
||||
.p-2 { padding: 12px; }
|
||||
.p-3 { padding: 18px; }
|
||||
.p-4 { padding: 24px; }
|
||||
.p-5 { padding: 36px; }
|
||||
.p-6 { padding: 48px; }
|
||||
.p-8 { padding: 72px; }
|
||||
|
||||
.m-0 { margin: 0; }
|
||||
.m-1 { margin: 6px; }
|
||||
.m-2 { margin: 12px; }
|
||||
.m-3 { margin: 18px; }
|
||||
.m-4 { margin: 24px; }
|
||||
.m-5 { margin: 36px; }
|
||||
.m-6 { margin: 48px; }
|
||||
.m-8 { margin: 72px; }
|
||||
|
||||
.mt-1 { margin-top: 6px; }
|
||||
.mt-2 { margin-top: 12px; }
|
||||
.mt-3 { margin-top: 18px; }
|
||||
.mt-4 { margin-top: 24px; }
|
||||
|
||||
.mb-1 { margin-bottom: 6px; }
|
||||
.mb-2 { margin-bottom: 12px; }
|
||||
.mb-3 { margin-bottom: 18px; }
|
||||
.mb-4 { margin-bottom: 24px; }
|
||||
|
||||
/* ============== Layout Utilities ============== */
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-around {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ============== Border Utilities ============== */
|
||||
|
||||
.rounded {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.rounded-lg {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.rounded-xl {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.rounded-full {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* ============== Screen Structure ============== */
|
||||
|
||||
.screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #121212;
|
||||
}
|
||||
|
||||
.screen-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* ============== Animations ============== */
|
||||
|
||||
@keyframes fade-in {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
0% { transform: translateX(100px); opacity: 0; }
|
||||
100% { transform: translateX(0px); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
0% { transform: translateX(-100px); opacity: 0; }
|
||||
100% { transform: translateX(0px); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
0% { transform: translateY(50px); opacity: 0; }
|
||||
100% { transform: translateY(0px); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
0% { transform: scale(0.9); opacity: 0; }
|
||||
100% { transform: scale(1.0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Hover highlight animation */
|
||||
@keyframes hover-pulse {
|
||||
0% { background-color: rgba(255, 255, 255, 0.0); }
|
||||
50% { background-color: rgba(255, 255, 255, 0.15); }
|
||||
100% { background-color: rgba(255, 255, 255, 0.1); }
|
||||
}
|
||||
|
||||
/* Screen transition classes */
|
||||
.nav-forward {
|
||||
animation: 0.2s cubic-out slide-in-right;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
animation: 0.2s cubic-out slide-in-left;
|
||||
}
|
||||
|
||||
.nav-home {
|
||||
animation: 0.2s back-out scale-in;
|
||||
}
|
||||
|
||||
.nav-default {
|
||||
animation: 0.15s cubic-out fade-in;
|
||||
}
|
||||
|
||||
/* Animation utility classes */
|
||||
.animate-fade-in {
|
||||
animation: 0.2s cubic-out fade-in;
|
||||
}
|
||||
|
||||
.animate-slide-right {
|
||||
animation: 0.25s cubic-out slide-in-right;
|
||||
}
|
||||
|
||||
.animate-slide-left {
|
||||
animation: 0.25s cubic-out slide-in-left;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: 0.2s cubic-out slide-up;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: 0.2s back-out scale-in;
|
||||
}
|
||||
|
||||
/* ============== Interactive Base Class ============== */
|
||||
/* All interactive elements should use cursor: pointer and have hover feedback */
|
||||
|
||||
.interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.interactive:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.interactive:active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
111
core/CMakeLists.txt
Normal file
111
core/CMakeLists.txt
Normal file
@@ -0,0 +1,111 @@
|
||||
cmake_minimum_required(VERSION 3.18)
|
||||
project(mosis-core VERSION 1.0.0 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
# Core library sources - portable sandbox APIs
|
||||
set(MOSIS_CORE_SOURCES
|
||||
# Utility
|
||||
src/logger.cpp
|
||||
|
||||
# Sandbox APIs (portable)
|
||||
src/sandbox/timer_manager.cpp
|
||||
src/sandbox/json_api.cpp
|
||||
src/sandbox/crypto_api.cpp
|
||||
src/sandbox/virtual_fs.cpp
|
||||
src/sandbox/lua_sandbox.cpp
|
||||
src/sandbox/permission_gate.cpp
|
||||
src/sandbox/audit_log.cpp
|
||||
src/sandbox/rate_limiter.cpp
|
||||
src/sandbox/path_sandbox.cpp
|
||||
)
|
||||
|
||||
# Optional sources that require additional dependencies
|
||||
if(MOSIS_ENABLE_DATABASE)
|
||||
list(APPEND MOSIS_CORE_SOURCES src/sandbox/database_manager.cpp)
|
||||
endif()
|
||||
|
||||
if(MOSIS_ENABLE_NETWORK)
|
||||
list(APPEND MOSIS_CORE_SOURCES
|
||||
src/sandbox/network_manager.cpp
|
||||
src/sandbox/http_validator.cpp
|
||||
)
|
||||
endif()
|
||||
|
||||
# Create static library
|
||||
add_library(mosis-core STATIC ${MOSIS_CORE_SOURCES})
|
||||
|
||||
# Include directories
|
||||
target_include_directories(mosis-core PUBLIC
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include/mosis>
|
||||
$<INSTALL_INTERFACE:include>
|
||||
)
|
||||
|
||||
# Also add internal include path for relative includes within library
|
||||
target_include_directories(mosis-core PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/mosis/sandbox
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/mosis/util
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/mosis/apps
|
||||
)
|
||||
|
||||
# Platform-specific definitions
|
||||
if(ANDROID)
|
||||
target_compile_definitions(mosis-core PUBLIC MOSIS_PLATFORM_ANDROID)
|
||||
elseif(WIN32)
|
||||
target_compile_definitions(mosis-core PUBLIC MOSIS_PLATFORM_WINDOWS)
|
||||
# Windows crypto library
|
||||
target_link_libraries(mosis-core PRIVATE bcrypt)
|
||||
elseif(APPLE)
|
||||
target_compile_definitions(mosis-core PUBLIC MOSIS_PLATFORM_MACOS)
|
||||
else()
|
||||
target_compile_definitions(mosis-core PUBLIC MOSIS_PLATFORM_LINUX)
|
||||
endif()
|
||||
|
||||
# OpenSSL for crypto on non-Windows platforms
|
||||
if(NOT WIN32)
|
||||
find_package(OpenSSL QUIET)
|
||||
if(OpenSSL_FOUND)
|
||||
target_link_libraries(mosis-core PRIVATE OpenSSL::Crypto)
|
||||
target_compile_definitions(mosis-core PRIVATE MOSIS_HAS_OPENSSL)
|
||||
else()
|
||||
message(WARNING "OpenSSL not found - crypto functions will be stubs")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Lua is required - parent project must provide it
|
||||
if(TARGET lua_static)
|
||||
target_link_libraries(mosis-core PUBLIC lua_static)
|
||||
elseif(TARGET lua)
|
||||
target_link_libraries(mosis-core PUBLIC lua)
|
||||
else()
|
||||
# Try to find Lua
|
||||
find_package(Lua QUIET)
|
||||
if(LUA_FOUND)
|
||||
target_include_directories(mosis-core PUBLIC ${LUA_INCLUDE_DIR})
|
||||
target_link_libraries(mosis-core PUBLIC ${LUA_LIBRARIES})
|
||||
else()
|
||||
message(WARNING "Lua not found - parent project must provide Lua target")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# JSON library (nlohmann/json - header only)
|
||||
if(TARGET nlohmann_json::nlohmann_json)
|
||||
target_link_libraries(mosis-core PUBLIC nlohmann_json::nlohmann_json)
|
||||
else()
|
||||
find_package(nlohmann_json QUIET)
|
||||
if(nlohmann_json_FOUND)
|
||||
target_link_libraries(mosis-core PUBLIC nlohmann_json::nlohmann_json)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# SQLite for database_manager (optional)
|
||||
if(MOSIS_ENABLE_DATABASE)
|
||||
find_package(SQLite3 REQUIRED)
|
||||
target_link_libraries(mosis-core PRIVATE SQLite::SQLite3)
|
||||
target_compile_definitions(mosis-core PRIVATE MOSIS_HAS_SQLITE)
|
||||
endif()
|
||||
|
||||
# Export compile commands for IDE support
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
23
core/include/mosis/apps/app_api.h
Normal file
23
core/include/mosis/apps/app_api.h
Normal file
@@ -0,0 +1,23 @@
|
||||
// app_api.h - Lua API bindings for app management
|
||||
// Milestone 10: Device-Side App Management
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
struct lua_State;
|
||||
|
||||
namespace mosis {
|
||||
|
||||
class AppManager;
|
||||
class UpdateService;
|
||||
|
||||
// Register Lua APIs for app management
|
||||
// - mosis.apps.* - System apps only (App Store, Settings)
|
||||
// - mosis.app.* - All apps (info about current app)
|
||||
void RegisterAppAPIs(lua_State* L,
|
||||
AppManager* app_manager,
|
||||
UpdateService* update_service,
|
||||
const std::string& current_app_id,
|
||||
bool is_system_app);
|
||||
|
||||
} // namespace mosis
|
||||
167
core/include/mosis/apps/app_manager.h
Normal file
167
core/include/mosis/apps/app_manager.h
Normal file
@@ -0,0 +1,167 @@
|
||||
// app_manager.h - App installation and management
|
||||
// Milestone 10: Device-Side App Management
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
|
||||
namespace mosis {
|
||||
|
||||
// Forward declarations
|
||||
class LuaSandboxManager;
|
||||
|
||||
// Information about an installed app
|
||||
struct InstalledApp {
|
||||
std::string package_id;
|
||||
std::string name;
|
||||
std::string version_name;
|
||||
int version_code = 0;
|
||||
std::string install_path;
|
||||
std::vector<std::string> permissions;
|
||||
std::chrono::system_clock::time_point installed_at;
|
||||
std::chrono::system_clock::time_point updated_at;
|
||||
int64_t package_size = 0;
|
||||
int64_t data_size = 0;
|
||||
bool is_system_app = false;
|
||||
std::string entry_point;
|
||||
std::string icon_path;
|
||||
std::string developer_name;
|
||||
};
|
||||
|
||||
// Progress stages during installation
|
||||
struct InstallProgress {
|
||||
enum class Stage {
|
||||
Downloading,
|
||||
Verifying,
|
||||
Extracting,
|
||||
Registering,
|
||||
Complete,
|
||||
Failed
|
||||
};
|
||||
|
||||
Stage stage = Stage::Downloading;
|
||||
float progress = 0.0f; // 0.0 - 1.0
|
||||
std::string error;
|
||||
|
||||
static const char* StageName(Stage s) {
|
||||
switch (s) {
|
||||
case Stage::Downloading: return "downloading";
|
||||
case Stage::Verifying: return "verifying";
|
||||
case Stage::Extracting: return "extracting";
|
||||
case Stage::Registering: return "registering";
|
||||
case Stage::Complete: return "complete";
|
||||
case Stage::Failed: return "failed";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
using ProgressCallback = std::function<void(const InstallProgress&)>;
|
||||
|
||||
// Manifest parsed from package
|
||||
struct AppManifest {
|
||||
std::string id;
|
||||
std::string name;
|
||||
std::string version;
|
||||
int version_code = 0;
|
||||
std::string entry;
|
||||
std::string icon;
|
||||
std::string description;
|
||||
std::string developer_name;
|
||||
std::string developer_email;
|
||||
std::vector<std::string> permissions;
|
||||
int min_api_version = 1;
|
||||
};
|
||||
|
||||
class AppManager {
|
||||
public:
|
||||
explicit AppManager(const std::string& data_root);
|
||||
~AppManager();
|
||||
|
||||
// Prevent copying
|
||||
AppManager(const AppManager&) = delete;
|
||||
AppManager& operator=(const AppManager&) = delete;
|
||||
|
||||
// Installation from URL
|
||||
bool Install(const std::string& package_url,
|
||||
const std::string& signature,
|
||||
ProgressCallback callback);
|
||||
|
||||
// Installation from local file
|
||||
bool InstallFromFile(const std::string& package_path,
|
||||
ProgressCallback callback);
|
||||
|
||||
// Uninstallation
|
||||
bool Uninstall(const std::string& package_id, bool keep_data = false);
|
||||
|
||||
// Updates
|
||||
bool Update(const std::string& package_id,
|
||||
const std::string& package_url,
|
||||
const std::string& signature,
|
||||
ProgressCallback callback);
|
||||
|
||||
// Query installed apps
|
||||
std::vector<InstalledApp> GetInstalledApps() const;
|
||||
std::optional<InstalledApp> GetApp(const std::string& package_id) const;
|
||||
bool IsInstalled(const std::string& package_id) const;
|
||||
|
||||
// Data management
|
||||
int64_t GetAppDataSize(const std::string& package_id) const;
|
||||
bool ClearAppData(const std::string& package_id);
|
||||
bool ClearAppCache(const std::string& package_id);
|
||||
bool BackupAppData(const std::string& package_id);
|
||||
bool RestoreAppData(const std::string& package_id);
|
||||
|
||||
// App launching
|
||||
bool LaunchApp(const std::string& package_id);
|
||||
bool StopApp(const std::string& package_id);
|
||||
bool IsAppRunning(const std::string& package_id) const;
|
||||
|
||||
// Integration with sandbox manager
|
||||
void SetSandboxManager(LuaSandboxManager* manager);
|
||||
|
||||
// Get paths
|
||||
std::string GetDataRoot() const { return m_data_root; }
|
||||
std::string GetAppPath(const std::string& package_id) const;
|
||||
std::string GetAppDataPath(const std::string& package_id) const;
|
||||
std::string GetAppCachePath(const std::string& package_id) const;
|
||||
|
||||
// System apps registration
|
||||
void RegisterSystemApp(const InstalledApp& app);
|
||||
|
||||
private:
|
||||
// Package verification
|
||||
bool VerifyPackage(const std::string& path);
|
||||
bool VerifySignature(const std::string& path, const std::string& signature);
|
||||
|
||||
// Package operations
|
||||
std::optional<AppManifest> ExtractManifest(const std::string& package_path);
|
||||
bool ExtractPackage(const std::string& package_path, const std::string& dest_path);
|
||||
|
||||
// Download helper
|
||||
bool DownloadFile(const std::string& url, const std::string& dest_path,
|
||||
std::function<void(float)> progress_callback);
|
||||
|
||||
// Registry persistence
|
||||
void LoadInstalledApps();
|
||||
void SaveInstalledApps();
|
||||
|
||||
// Directory size calculation
|
||||
int64_t CalculateDirectorySize(const std::string& path) const;
|
||||
|
||||
// Generate unique ID
|
||||
std::string GenerateUUID() const;
|
||||
|
||||
std::string m_data_root;
|
||||
LuaSandboxManager* m_sandbox_manager = nullptr;
|
||||
mutable std::mutex m_mutex;
|
||||
std::map<std::string, InstalledApp> m_installed_apps;
|
||||
};
|
||||
|
||||
} // namespace mosis
|
||||
57
core/include/mosis/platform/asset_interface.h
Normal file
57
core/include/mosis/platform/asset_interface.h
Normal file
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
||||
namespace mosis {
|
||||
|
||||
/**
|
||||
* Platform-agnostic interface for loading assets.
|
||||
* Android implements this using AAssetManager.
|
||||
* Desktop implements this using filesystem operations.
|
||||
*/
|
||||
class IAssetInterface {
|
||||
public:
|
||||
virtual ~IAssetInterface() = default;
|
||||
|
||||
/**
|
||||
* Read entire file contents as bytes.
|
||||
* @param path Relative path to asset (e.g., "apps/home/home.rml")
|
||||
* @return File contents, or empty vector if not found
|
||||
*/
|
||||
virtual std::vector<uint8_t> ReadFile(const std::string& path) = 0;
|
||||
|
||||
/**
|
||||
* Read entire file contents as string.
|
||||
* @param path Relative path to asset
|
||||
* @return File contents, or empty string if not found
|
||||
*/
|
||||
virtual std::string ReadFileString(const std::string& path) = 0;
|
||||
|
||||
/**
|
||||
* Check if an asset exists.
|
||||
* @param path Relative path to asset
|
||||
* @return true if asset exists
|
||||
*/
|
||||
virtual bool Exists(const std::string& path) = 0;
|
||||
|
||||
/**
|
||||
* List files in a directory.
|
||||
* @param path Relative path to directory
|
||||
* @return List of filenames (not full paths)
|
||||
*/
|
||||
virtual std::vector<std::string> ListDirectory(const std::string& path) = 0;
|
||||
|
||||
/**
|
||||
* Get the absolute path for an asset (if applicable).
|
||||
* On Android this may return empty as assets are in APK.
|
||||
* @param path Relative path to asset
|
||||
* @return Absolute path or empty string
|
||||
*/
|
||||
virtual std::string GetAbsolutePath(const std::string& path) = 0;
|
||||
};
|
||||
|
||||
using AssetInterfacePtr = std::shared_ptr<IAssetInterface>;
|
||||
|
||||
} // namespace mosis
|
||||
98
core/include/mosis/platform/filesystem_interface.h
Normal file
98
core/include/mosis/platform/filesystem_interface.h
Normal file
@@ -0,0 +1,98 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <functional>
|
||||
|
||||
namespace mosis {
|
||||
|
||||
/**
|
||||
* Platform-agnostic interface for filesystem operations.
|
||||
* Used for app data storage, not assets.
|
||||
*/
|
||||
class IFilesystemInterface {
|
||||
public:
|
||||
virtual ~IFilesystemInterface() = default;
|
||||
|
||||
/**
|
||||
* Get the base data directory for apps.
|
||||
* Android: /data/data/com.omixlab.mosis/files/
|
||||
* Desktop: ./data/ or configurable
|
||||
*/
|
||||
virtual std::string GetDataRoot() = 0;
|
||||
|
||||
/**
|
||||
* Get the apps installation directory.
|
||||
* Contains installed app packages.
|
||||
*/
|
||||
virtual std::string GetAppsDirectory() = 0;
|
||||
|
||||
/**
|
||||
* Get app-specific data directory.
|
||||
* @param app_id Application ID (e.g., "com.example.app")
|
||||
*/
|
||||
virtual std::string GetAppDataDirectory(const std::string& app_id) = 0;
|
||||
|
||||
/**
|
||||
* Get app-specific cache directory.
|
||||
*/
|
||||
virtual std::string GetAppCacheDirectory(const std::string& app_id) = 0;
|
||||
|
||||
/**
|
||||
* Create directory if it doesn't exist.
|
||||
* @return true on success
|
||||
*/
|
||||
virtual bool CreateDirectory(const std::string& path) = 0;
|
||||
|
||||
/**
|
||||
* Check if path exists.
|
||||
*/
|
||||
virtual bool Exists(const std::string& path) = 0;
|
||||
|
||||
/**
|
||||
* Check if path is a directory.
|
||||
*/
|
||||
virtual bool IsDirectory(const std::string& path) = 0;
|
||||
|
||||
/**
|
||||
* Remove file or directory.
|
||||
* @param recursive If true, remove directory contents
|
||||
*/
|
||||
virtual bool Remove(const std::string& path, bool recursive = false) = 0;
|
||||
|
||||
/**
|
||||
* Read file contents.
|
||||
*/
|
||||
virtual std::vector<uint8_t> ReadFile(const std::string& path) = 0;
|
||||
|
||||
/**
|
||||
* Write file contents.
|
||||
*/
|
||||
virtual bool WriteFile(const std::string& path, const std::vector<uint8_t>& data) = 0;
|
||||
|
||||
/**
|
||||
* List directory contents.
|
||||
*/
|
||||
virtual std::vector<std::string> ListDirectory(const std::string& path) = 0;
|
||||
|
||||
/**
|
||||
* Get file size.
|
||||
* @return Size in bytes, or -1 if not found
|
||||
*/
|
||||
virtual int64_t GetFileSize(const std::string& path) = 0;
|
||||
|
||||
/**
|
||||
* Copy file.
|
||||
*/
|
||||
virtual bool CopyFile(const std::string& src, const std::string& dst) = 0;
|
||||
|
||||
/**
|
||||
* Move/rename file.
|
||||
*/
|
||||
virtual bool MoveFile(const std::string& src, const std::string& dst) = 0;
|
||||
};
|
||||
|
||||
using FilesystemInterfacePtr = std::shared_ptr<IFilesystemInterface>;
|
||||
|
||||
} // namespace mosis
|
||||
94
core/include/mosis/sandbox/audit_log.h
Normal file
94
core/include/mosis/sandbox/audit_log.h
Normal file
@@ -0,0 +1,94 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <mutex>
|
||||
#include <chrono>
|
||||
|
||||
namespace mosis {
|
||||
|
||||
enum class AuditEvent {
|
||||
// Lifecycle
|
||||
AppStart,
|
||||
AppStop,
|
||||
|
||||
// Permissions
|
||||
PermissionCheck,
|
||||
PermissionGranted,
|
||||
PermissionDenied,
|
||||
|
||||
// Network
|
||||
NetworkRequest,
|
||||
NetworkBlocked,
|
||||
|
||||
// Storage
|
||||
FileAccess,
|
||||
FileBlocked,
|
||||
DatabaseAccess,
|
||||
|
||||
// Hardware
|
||||
CameraAccess,
|
||||
MicrophoneAccess,
|
||||
LocationAccess,
|
||||
|
||||
// Security
|
||||
SandboxViolation,
|
||||
ResourceLimitHit,
|
||||
RateLimitHit,
|
||||
|
||||
// Other
|
||||
Custom
|
||||
};
|
||||
|
||||
struct AuditEntry {
|
||||
std::chrono::system_clock::time_point timestamp;
|
||||
AuditEvent event;
|
||||
std::string app_id;
|
||||
std::string details;
|
||||
bool success;
|
||||
};
|
||||
|
||||
class AuditLog {
|
||||
public:
|
||||
explicit AuditLog(size_t max_entries = 10000);
|
||||
|
||||
// Log an event
|
||||
void Log(AuditEvent event, const std::string& app_id,
|
||||
const std::string& details = "", bool success = true);
|
||||
|
||||
// Query entries (returns most recent first)
|
||||
std::vector<AuditEntry> GetEntries(size_t count = 100) const;
|
||||
std::vector<AuditEntry> GetEntriesForApp(const std::string& app_id,
|
||||
size_t count = 100) const;
|
||||
std::vector<AuditEntry> GetEntriesByEvent(AuditEvent event,
|
||||
size_t count = 100) const;
|
||||
|
||||
// Statistics
|
||||
size_t GetTotalEntries() const;
|
||||
size_t GetStoredEntries() const;
|
||||
size_t CountEvents(AuditEvent event, const std::string& app_id = "") const;
|
||||
|
||||
// Clear all entries
|
||||
void Clear();
|
||||
|
||||
// Convert event to string for logging
|
||||
static const char* EventToString(AuditEvent event);
|
||||
|
||||
private:
|
||||
mutable std::mutex m_mutex;
|
||||
std::vector<AuditEntry> m_entries;
|
||||
size_t m_max_entries;
|
||||
size_t m_write_index = 0;
|
||||
size_t m_total_logged = 0;
|
||||
bool m_wrapped = false;
|
||||
};
|
||||
|
||||
// Global audit log (singleton)
|
||||
AuditLog& GetAuditLog();
|
||||
|
||||
} // namespace mosis
|
||||
|
||||
// Convenience alias
|
||||
using AuditLog = mosis::AuditLog;
|
||||
using AuditEvent = mosis::AuditEvent;
|
||||
using AuditEntry = mosis::AuditEntry;
|
||||
52
core/include/mosis/sandbox/crypto_api.h
Normal file
52
core/include/mosis/sandbox/crypto_api.h
Normal file
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
#include <random>
|
||||
#include <mutex>
|
||||
|
||||
struct lua_State;
|
||||
|
||||
namespace mosis {
|
||||
|
||||
// Per-app cryptographically secure RNG
|
||||
class SecureRandom {
|
||||
public:
|
||||
SecureRandom();
|
||||
|
||||
// Get random bytes as binary string
|
||||
std::string GetBytes(size_t count);
|
||||
|
||||
// Get random integer in range [min, max]
|
||||
int64_t GetInt(int64_t min, int64_t max);
|
||||
|
||||
// Get random double in range [0.0, 1.0)
|
||||
double GetDouble();
|
||||
|
||||
private:
|
||||
std::random_device m_rd;
|
||||
std::mt19937_64 m_gen;
|
||||
std::mutex m_mutex;
|
||||
};
|
||||
|
||||
// Hash algorithms supported
|
||||
enum class HashAlgorithm {
|
||||
SHA256,
|
||||
SHA512,
|
||||
SHA1,
|
||||
MD5
|
||||
};
|
||||
|
||||
// Compute hash of data
|
||||
std::string ComputeHash(HashAlgorithm algo, const std::string& data);
|
||||
|
||||
// Compute HMAC of data with key
|
||||
std::string ComputeHMAC(HashAlgorithm algo, const std::string& key, const std::string& data);
|
||||
|
||||
// Register crypto.* APIs as globals
|
||||
void RegisterCryptoAPI(lua_State* L);
|
||||
|
||||
// Register secure math.random replacement (removes math.randomseed)
|
||||
void RegisterSecureMathRandom(lua_State* L, SecureRandom* rng);
|
||||
|
||||
} // namespace mosis
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user