vaderjs-native 1.0.10 → 1.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +398 -58
- package/app-template/app/src/main/java/myapp/MainActivity.kt +253 -52
- package/index.ts +342 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# VaderNative
|
|
2
|
+
|
|
1
3
|
<p align="center">
|
|
2
4
|
<a href="https://vader-js.pages.dev">
|
|
3
5
|
<picture>
|
|
@@ -8,87 +10,156 @@
|
|
|
8
10
|
</a>
|
|
9
11
|
</p>
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
**A modern, reactive framework for building ultra-fast native mobile apps — minimal, blazing fast, and easy to learn.**
|
|
13
|
+
**A modern, reactive framework for building ultra-fast native mobile apps with comprehensive Android bridge integration — minimal, blazing fast, and feature-rich.**
|
|
14
14
|
|
|
15
|
-
[](https://github.com/Postr-Inc/Vader.js/blob/main/LICENSE)
|
|
15
|
+
[](https://github.com/Postr-Inc/Vader.js/blob/main/LICENSE)
|
|
16
|
+
[](https://www.npmjs.com/package/vaderjs)
|
|
16
17
|
|
|
17
18
|
---
|
|
18
19
|
|
|
19
|
-
##
|
|
20
|
+
## ✨ Why Choose VaderNative?
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
* ✅ **Ultra-minimal reactivity** – no virtual DOM overhead, direct DOM updates
|
|
23
|
+
* ✅ **Full Android bridge integration** – access native device features seamlessly
|
|
24
|
+
* ✅ **File-based routing** – Next.js-inspired, production-ready
|
|
25
|
+
* ✅ **Tiny runtime** – under 20KB gzipped
|
|
26
|
+
* ✅ **React-inspired API** – familiar hooks and components
|
|
27
|
+
* ✅ **D-pad navigation** – built-in TV and remote control support
|
|
28
|
+
* ✅ **Persistent file system** – read/write files directly on Android
|
|
29
|
+
* ✅ **Comprehensive hooks** – all essential React hooks implemented
|
|
30
|
+
* ✅ **TypeScript ready** – full type definitions included
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
const [count, setCount] = Vader.useState(0);
|
|
32
|
+
---
|
|
26
33
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
## 🚀 Feature Highlights
|
|
35
|
+
|
|
36
|
+
### **Native Android Bridge**
|
|
37
|
+
- **File System Access**: Read, write, delete, and list files directly on Android
|
|
38
|
+
- **Native Dialogs**: System-level alert and confirmation dialogs
|
|
39
|
+
- **Toast Notifications**: Native Android toast messages
|
|
40
|
+
- **Permissions Management**: Request camera, storage, microphone, and notification permissions
|
|
41
|
+
- **D-pad Navigation**: Full TV remote and game controller support
|
|
42
|
+
- **Hardware Keys**: Handle back button, media keys, and navigation buttons
|
|
43
|
+
|
|
44
|
+
### **Modern React API**
|
|
45
|
+
- **All Essential Hooks**: `useState`, `useEffect`, `useReducer`, `useMemo`, `useCallback`, `useRef`
|
|
46
|
+
- **Advanced Hooks**: `useQuery`, `useArray`, `useWindowFocus`, `useLocalStorage`, `useInterval`
|
|
47
|
+
- **Conditional Rendering**: `Switch`, `Match`, `Show` components
|
|
48
|
+
- **Context API**: Full `createContext` and `useContext` support
|
|
49
|
+
- **Focus Management**: Automatic D-pad navigation for TV apps
|
|
50
|
+
|
|
51
|
+
### **Performance First**
|
|
52
|
+
- **Zero Virtual DOM**: Direct DOM reconciliation
|
|
53
|
+
- **Smart Reconciliation**: Key-based diffing algorithm
|
|
54
|
+
- **RequestAnimationFrame**: Optimized rendering loop
|
|
55
|
+
- **Memory Efficient**: Minimal garbage collection pressure
|
|
43
56
|
|
|
44
57
|
---
|
|
45
58
|
|
|
46
59
|
## 📦 Installation
|
|
47
60
|
|
|
48
61
|
```bash
|
|
62
|
+
# Using bun (recommended)
|
|
49
63
|
bun install vaderjs@latest
|
|
64
|
+
|
|
65
|
+
# Using npm
|
|
66
|
+
npm install vaderjs@latest
|
|
67
|
+
|
|
68
|
+
# Using yarn
|
|
69
|
+
yarn add vaderjs@latest
|
|
50
70
|
```
|
|
51
71
|
|
|
52
72
|
---
|
|
53
73
|
|
|
54
|
-
##
|
|
74
|
+
## ⚡ Quick Start
|
|
55
75
|
|
|
56
|
-
|
|
76
|
+
### Counter App with Android Features
|
|
57
77
|
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
78
|
+
```tsx
|
|
79
|
+
import * as Vader from "vaderjs-native";
|
|
80
|
+
import { useState, useEffect, showToast, FileSystem } from "vaderjs-native";
|
|
81
|
+
|
|
82
|
+
function Counter() {
|
|
83
|
+
const [count, setCount] = useState(0);
|
|
84
|
+
|
|
85
|
+
// Load count from Android file system on startup
|
|
86
|
+
useEffect(async () => {
|
|
87
|
+
const data = await FileSystem.loadJSON("counter_data.json");
|
|
88
|
+
if (data?.count) setCount(data.count);
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
// Save count to file whenever it changes
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
FileSystem.saveJSON("counter_data.json", { count, timestamp: Date.now() });
|
|
94
|
+
}, [count]);
|
|
95
|
+
|
|
96
|
+
const increment = () => {
|
|
97
|
+
setCount(count + 1);
|
|
98
|
+
showToast(`Count: ${count + 1}`);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div style={{ padding: 20, backgroundColor: "#1a1a1a", minHeight: "100vh" }}>
|
|
103
|
+
<h1 style={{ color: "white", fontSize: 48 }}>Persistent Counter</h1>
|
|
104
|
+
<p style={{ color: "#ccc", fontSize: 24 }}>Count: {count}</p>
|
|
105
|
+
<button
|
|
106
|
+
onClick={increment}
|
|
107
|
+
style={{
|
|
108
|
+
padding: "12px 24px",
|
|
109
|
+
fontSize: 18,
|
|
110
|
+
backgroundColor: "#007AFF",
|
|
111
|
+
color: "white",
|
|
112
|
+
border: "none",
|
|
113
|
+
borderRadius: 8
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
Increment (+1)
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
65
121
|
|
|
66
|
-
|
|
122
|
+
Vader.render(<Counter />, document.getElementById("app"));
|
|
123
|
+
```
|
|
67
124
|
|
|
68
125
|
---
|
|
69
126
|
|
|
70
|
-
##
|
|
127
|
+
## 📁 Project Structure
|
|
128
|
+
|
|
129
|
+
VaderNative uses file-based routing similar to Next.js:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
/app
|
|
133
|
+
/index.jsx -> Homepage (/)
|
|
134
|
+
/about.jsx -> About page (/about)
|
|
135
|
+
/users/[id].jsx -> Dynamic route (/users/:id)
|
|
136
|
+
/settings/[[...slug]].jsx -> Optional catch-all (/settings/*)
|
|
137
|
+
|
|
138
|
+
/src
|
|
139
|
+
/components -> Shared components
|
|
140
|
+
/hooks -> Custom hooks
|
|
141
|
+
/utils -> Utility functions
|
|
71
142
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
143
|
+
/public
|
|
144
|
+
/assets -> Static assets (images, fonts)
|
|
145
|
+
|
|
146
|
+
vader.config.ts -> App configuration
|
|
147
|
+
```
|
|
77
148
|
|
|
78
149
|
---
|
|
79
150
|
|
|
80
151
|
## 🛠 Configuration
|
|
81
152
|
|
|
82
|
-
Create a `vader.config.ts`
|
|
153
|
+
Create a `vader.config.ts` file:
|
|
83
154
|
|
|
84
|
-
```
|
|
155
|
+
```typescript
|
|
85
156
|
import defineConfig from "vaderjs-native/config";
|
|
86
157
|
import tailwind from "vaderjs-native/plugins/tailwind";
|
|
87
158
|
|
|
88
159
|
export default defineConfig({
|
|
89
160
|
app: {
|
|
90
|
-
name: "VaderNative App",
|
|
91
|
-
id: "com.
|
|
161
|
+
name: "My VaderNative App",
|
|
162
|
+
id: "com.example.app",
|
|
92
163
|
version: {
|
|
93
164
|
code: 1,
|
|
94
165
|
name: "1.0.0",
|
|
@@ -99,16 +170,18 @@ export default defineConfig({
|
|
|
99
170
|
android: {
|
|
100
171
|
minSdk: 24,
|
|
101
172
|
targetSdk: 34,
|
|
102
|
-
permissions: [
|
|
103
|
-
|
|
104
|
-
|
|
173
|
+
permissions: [
|
|
174
|
+
"INTERNET",
|
|
175
|
+
"ACCESS_NETWORK_STATE",
|
|
176
|
+
"READ_EXTERNAL_STORAGE",
|
|
177
|
+
"WRITE_EXTERNAL_STORAGE",
|
|
178
|
+
"CAMERA"
|
|
179
|
+
],
|
|
105
180
|
},
|
|
106
181
|
web: {
|
|
107
182
|
title: "VaderNative App",
|
|
108
183
|
themeColor: "#111827",
|
|
109
184
|
},
|
|
110
|
-
ios: {},
|
|
111
|
-
windows: {},
|
|
112
185
|
},
|
|
113
186
|
|
|
114
187
|
plugins: [tailwind]
|
|
@@ -117,14 +190,281 @@ export default defineConfig({
|
|
|
117
190
|
|
|
118
191
|
---
|
|
119
192
|
|
|
120
|
-
##
|
|
193
|
+
## 🔧 Core API Reference
|
|
194
|
+
|
|
195
|
+
### **Hooks**
|
|
196
|
+
|
|
197
|
+
| Hook | Description | Example |
|
|
198
|
+
|------|-------------|---------|
|
|
199
|
+
| `useState` | Component state management | `const [count, setCount] = useState(0)` |
|
|
200
|
+
| `useEffect` | Side effects and lifecycle | `useEffect(() => { fetchData() }, [])` |
|
|
201
|
+
| `useReducer` | Complex state logic | `const [state, dispatch] = useReducer(reducer, initialState)` |
|
|
202
|
+
| `useMemo` | Memoize expensive calculations | `const value = useMemo(() => compute(a, b), [a, b])` |
|
|
203
|
+
| `useCallback` | Memoize callback functions | `const cb = useCallback(() => doSomething(), [])` |
|
|
204
|
+
| `useRef` | Mutable references | `const ref = useRef(null)` |
|
|
205
|
+
| `useContext` | Context consumption | `const value = useContext(MyContext)` |
|
|
206
|
+
| `useArray` | Array state with helpers | `const {array, add, remove} = useArray([])` |
|
|
207
|
+
| `useQuery` | Data fetching with caching | `const {data, loading} = useQuery("/api/data")` |
|
|
208
|
+
| `useLocalStorage` | Sync state with localStorage | `const [value, setValue] = useLocalStorage("key", init)` |
|
|
209
|
+
| `useInterval` | Run functions at intervals | `useInterval(() => {}, 1000)` |
|
|
210
|
+
| `useWindowFocus` | Track window focus state | `const isFocused = useWindowFocus()` |
|
|
211
|
+
| `useOnClickOutside` | Detect clicks outside element | `useOnClickOutside(ref, handler)` |
|
|
212
|
+
|
|
213
|
+
### **Android Bridge Features**
|
|
214
|
+
|
|
215
|
+
| Feature | Method | Description |
|
|
216
|
+
|---------|--------|-------------|
|
|
217
|
+
| **File System** | `FileSystem.saveJSON()` | Save JSON data to Android storage |
|
|
218
|
+
| | `FileSystem.loadJSON()` | Load JSON data from storage |
|
|
219
|
+
| | `FileSystem.exists()` | Check if file exists |
|
|
220
|
+
| | `FS.readFile()` | Read any file as string |
|
|
221
|
+
| | `FS.writeFile()` | Write any file content |
|
|
222
|
+
| | `FS.deleteFile()` | Delete files |
|
|
223
|
+
| | `FS.listDir()` | List directory contents |
|
|
224
|
+
| **UI** | `showToast()` | Show native Android toast |
|
|
225
|
+
| | `useDialog().alert()` | Native alert dialog |
|
|
226
|
+
| | `useDialog().confirm()` | Native confirmation dialog |
|
|
227
|
+
| **Permissions** | `usePermission().request()` | Request Android permissions |
|
|
228
|
+
| | `usePermission().has()` | Check permission status |
|
|
229
|
+
| **Navigation** | `Link` component | Native-aware navigation |
|
|
230
|
+
| | Android back button | Automatic handling |
|
|
231
|
+
| **D-pad** | Auto-focus management | TV remote navigation |
|
|
232
|
+
| | Hardware key support | Game controllers, remotes |
|
|
233
|
+
|
|
234
|
+
### **Components**
|
|
235
|
+
|
|
236
|
+
| Component | Description | Example |
|
|
237
|
+
|-----------|-------------|---------|
|
|
238
|
+
| `Switch` | Conditional rendering switcher | `<Switch>{matches}</Switch>` |
|
|
239
|
+
| `Match` | Condition for Switch | `<Match when={cond}>{content}</Match>` |
|
|
240
|
+
| `Show` | Conditional render | `<Show when={cond}>{content}</Show>` |
|
|
241
|
+
| `Link` | Navigation link | `<Link to="/about">About</Link>` |
|
|
242
|
+
|
|
243
|
+
### **Focus Management**
|
|
244
|
+
|
|
245
|
+
VaderNative includes a sophisticated D-pad navigation system for TV apps:
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
// Automatic focus management - no setup needed!
|
|
249
|
+
// Just use standard HTML elements:
|
|
250
|
+
|
|
251
|
+
<button>Button 1</button> // Focusable with D-pad
|
|
252
|
+
<a href="#">Link</a> // Focusable with D-pad
|
|
253
|
+
<input type="text" /> // Focusable with D-pad
|
|
254
|
+
|
|
255
|
+
// Manual focus control:
|
|
256
|
+
const focusManager = new FocusManager();
|
|
257
|
+
focusManager.focusFirst(); // Focus first element
|
|
258
|
+
focusManager.focusElement(el); // Focus specific element
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## 📊 File System Example
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
import { FileSystem, useEffect, useState } from "vaderjs-native";
|
|
267
|
+
|
|
268
|
+
function UserProfile() {
|
|
269
|
+
const [user, setUser] = useState(null);
|
|
270
|
+
|
|
271
|
+
useEffect(async () => {
|
|
272
|
+
// Load user data from Android storage
|
|
273
|
+
const savedUser = await FileSystem.loadJSON("user_profile.json");
|
|
274
|
+
if (savedUser) {
|
|
275
|
+
setUser(savedUser);
|
|
276
|
+
} else {
|
|
277
|
+
// Create default profile
|
|
278
|
+
const defaultUser = {
|
|
279
|
+
name: "Guest",
|
|
280
|
+
theme: "dark",
|
|
281
|
+
preferences: { language: "en" }
|
|
282
|
+
};
|
|
283
|
+
await FileSystem.saveJSON("user_profile.json", defaultUser);
|
|
284
|
+
setUser(defaultUser);
|
|
285
|
+
}
|
|
286
|
+
}, []);
|
|
287
|
+
|
|
288
|
+
const saveProfile = async (updates) => {
|
|
289
|
+
const updated = { ...user, ...updates };
|
|
290
|
+
const success = await FileSystem.saveJSON("user_profile.json", updated);
|
|
291
|
+
if (success) {
|
|
292
|
+
setUser(updated);
|
|
293
|
+
showToast("Profile saved!");
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
return (
|
|
298
|
+
<div>
|
|
299
|
+
<h1>{user?.name}'s Profile</h1>
|
|
300
|
+
<button onClick={() => saveProfile({ theme: "light" })}>
|
|
301
|
+
Set Light Theme
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## 📱 Android-Specific Features
|
|
311
|
+
|
|
312
|
+
### **Permission Management**
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
const permissions = usePermission();
|
|
316
|
+
|
|
317
|
+
// Request camera access
|
|
318
|
+
const takePhoto = async () => {
|
|
319
|
+
const hasCamera = await permissions.has("camera");
|
|
320
|
+
if (!hasCamera) {
|
|
321
|
+
const granted = await permissions.request("camera");
|
|
322
|
+
if (!granted) {
|
|
323
|
+
showToast("Camera permission required");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// Take photo logic...
|
|
328
|
+
};
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### **Native Dialogs**
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
const dialog = useDialog();
|
|
335
|
+
|
|
336
|
+
const deleteItem = async () => {
|
|
337
|
+
const confirmed = await dialog.confirm({
|
|
338
|
+
title: "Delete Item",
|
|
339
|
+
message: "Are you sure you want to delete this item?",
|
|
340
|
+
okText: "Delete",
|
|
341
|
+
cancelText: "Cancel"
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
if (confirmed) {
|
|
345
|
+
// Delete logic...
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
```
|
|
121
349
|
|
|
122
|
-
|
|
123
|
-
* ✅ **Native components** – fast and lightweight
|
|
124
|
-
* ✅ **File-based routing** – simple, built-in, production-ready
|
|
125
|
-
* ✅ **Tiny runtime** – blazing fast updates
|
|
126
|
-
* ✅ **React-inspired API** – familiar but simpler
|
|
350
|
+
### **D-pad Navigation Events**
|
|
127
351
|
|
|
128
|
-
|
|
352
|
+
```javascript
|
|
353
|
+
// Listen for D-pad navigation events
|
|
354
|
+
document.addEventListener('dpadfocus', (e) => {
|
|
355
|
+
console.log('Element focused:', e.detail.element);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Listen for back button
|
|
359
|
+
document.addEventListener('dpadback', () => {
|
|
360
|
+
// Handle back navigation
|
|
361
|
+
});
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
## 🎯 Performance Features
|
|
367
|
+
|
|
368
|
+
### **Automatic Batching**
|
|
369
|
+
State updates are automatically batched for optimal performance.
|
|
370
|
+
|
|
371
|
+
### **Key-Based Reconciliation**
|
|
372
|
+
Efficient DOM updates using React-like key prop support.
|
|
373
|
+
|
|
374
|
+
### **Smart Re-rendering**
|
|
375
|
+
Components only re-render when their props or state actually change.
|
|
376
|
+
|
|
377
|
+
### **Minimal Bundle Size**
|
|
378
|
+
- Core runtime: < 20KB
|
|
379
|
+
- Full feature set: < 25KB
|
|
380
|
+
- No external dependencies
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
## 🔄 Data Fetching with Caching
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
import { useQuery } from "vaderjs-native";
|
|
388
|
+
|
|
389
|
+
function DataDashboard() {
|
|
390
|
+
// Automatically caches for 5 minutes
|
|
391
|
+
const { data, loading, error, refetch } = useQuery(
|
|
392
|
+
"https://api.example.com/data",
|
|
393
|
+
{ expiryMs: 300000 } // 5 minutes cache
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
if (loading) return <div>Loading...</div>;
|
|
397
|
+
if (error) return <div>Error: {error.message}</div>;
|
|
398
|
+
|
|
399
|
+
return (
|
|
400
|
+
<div>
|
|
401
|
+
<h1>Dashboard</h1>
|
|
402
|
+
<pre>{JSON.stringify(data, null, 2)}</pre>
|
|
403
|
+
<button onClick={refetch}>Refresh</button>
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
## 📦 Building for Production
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
# Build for Android
|
|
415
|
+
bun run build:android
|
|
416
|
+
|
|
417
|
+
# Build for Web
|
|
418
|
+
bun run build:web
|
|
419
|
+
|
|
420
|
+
# Development server with hot reload
|
|
421
|
+
bun run dev
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
## 🆚 Comparison with Other Frameworks
|
|
427
|
+
|
|
428
|
+
| Feature | VaderNative | React Native | Flutter | NativeScript |
|
|
429
|
+
|---------|-------------|--------------|---------|--------------|
|
|
430
|
+
| **Bundle Size** | < 25KB | ~2MB | ~5MB | ~5MB |
|
|
431
|
+
| **Startup Time** | Instant | Slow | Slow | Medium |
|
|
432
|
+
| **Android Bridge** | Built-in | Via Native Modules | Via Plugins | Via Plugins |
|
|
433
|
+
| **D-pad Support** | Built-in | Requires Setup | Requires Setup | Requires Setup |
|
|
434
|
+
| **File System** | Direct Access | AsyncStorage | path_provider | file-system |
|
|
435
|
+
| **Learning Curve** | Easy (React-like) | Medium | Hard (Dart) | Medium |
|
|
436
|
+
| **Memory Usage** | Very Low | High | High | Medium |
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
## 🚀 Getting Started Template
|
|
441
|
+
|
|
442
|
+
```bash
|
|
443
|
+
# Create new VaderNative project
|
|
444
|
+
bun create vader-native my-app
|
|
445
|
+
cd my-app
|
|
446
|
+
|
|
447
|
+
# Install dependencies
|
|
448
|
+
bun install
|
|
449
|
+
|
|
450
|
+
# Start development server
|
|
451
|
+
bun run dev
|
|
452
|
+
|
|
453
|
+
# Build for production
|
|
454
|
+
bun run build:android
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
---
|
|
458
|
+
|
|
459
|
+
## 🤝 Contributing
|
|
460
|
+
|
|
461
|
+
We welcome contributions! Please see our [Contributing Guide](https://github.com/Postr-Inc/Vader.js/blob/main/CONTRIBUTING.md) for details.
|
|
462
|
+
|
|
463
|
+
## 📄 License
|
|
464
|
+
|
|
465
|
+
VaderNative is [MIT licensed](https://github.com/Postr-Inc/Vader.js/blob/main/LICENSE).
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
---
|
|
129
469
|
|
|
130
|
-
|
|
470
|
+
**Ready to build lightning-fast native apps?** [Get Started →](https://vader-js.pages.dev)
|
|
@@ -1,24 +1,34 @@
|
|
|
1
1
|
package com.example.myapplication
|
|
2
|
-
import androidx.activity.OnBackPressedCallback
|
|
3
2
|
|
|
3
|
+
import android.app.AlertDialog
|
|
4
4
|
import android.annotation.SuppressLint
|
|
5
5
|
import android.content.Context
|
|
6
|
+
import android.content.pm.PackageManager
|
|
6
7
|
import android.os.Bundle
|
|
7
8
|
import android.os.Message
|
|
8
9
|
import android.view.KeyEvent
|
|
9
10
|
import android.webkit.JavascriptInterface
|
|
11
|
+
import android.webkit.WebChromeClient
|
|
10
12
|
import android.webkit.WebView
|
|
11
13
|
import android.webkit.WebViewClient
|
|
12
|
-
import android.webkit.WebChromeClient
|
|
13
14
|
import android.widget.Toast
|
|
14
15
|
import androidx.activity.ComponentActivity
|
|
16
|
+
import androidx.activity.OnBackPressedCallback
|
|
17
|
+
import androidx.core.app.ActivityCompat
|
|
18
|
+
import androidx.core.content.ContextCompat
|
|
19
|
+
import org.json.JSONArray
|
|
20
|
+
import java.io.FileNotFoundException
|
|
15
21
|
import java.net.HttpURLConnection
|
|
16
22
|
import java.net.URL
|
|
17
23
|
|
|
18
24
|
class MainActivity : ComponentActivity() {
|
|
25
|
+
|
|
19
26
|
lateinit var webView: WebView
|
|
27
|
+
lateinit var androidBridge: AndroidBridge
|
|
20
28
|
|
|
21
|
-
private
|
|
29
|
+
private val baseUrl = "file:///android_asset/myapp/index.html"
|
|
30
|
+
|
|
31
|
+
@SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface")
|
|
22
32
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
23
33
|
super.onCreate(savedInstanceState)
|
|
24
34
|
|
|
@@ -26,63 +36,95 @@ class MainActivity : ComponentActivity() {
|
|
|
26
36
|
setContentView(webView)
|
|
27
37
|
|
|
28
38
|
// --- WebView Settings ---
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
webView.settings.apply {
|
|
40
|
+
javaScriptEnabled = true
|
|
41
|
+
allowFileAccess = true
|
|
42
|
+
allowContentAccess = true
|
|
43
|
+
allowFileAccessFromFileURLs = true
|
|
44
|
+
allowUniversalAccessFromFileURLs = true
|
|
45
|
+
domStorageEnabled = true
|
|
46
|
+
databaseEnabled = true
|
|
47
|
+
mediaPlaybackRequiresUserGesture = false
|
|
48
|
+
|
|
49
|
+
// Basic compatibility settings
|
|
50
|
+
setSupportMultipleWindows(false)
|
|
51
|
+
loadWithOverviewMode = true
|
|
52
|
+
useWideViewPort = true
|
|
53
|
+
builtInZoomControls = true
|
|
54
|
+
displayZoomControls = false
|
|
55
|
+
setSupportZoom(true)
|
|
56
|
+
|
|
57
|
+
// Performance optimizations
|
|
58
|
+
javaScriptCanOpenWindowsAutomatically = false
|
|
59
|
+
loadsImagesAutomatically = true
|
|
60
|
+
}
|
|
61
|
+
|
|
37
62
|
webView.isFocusable = true
|
|
38
63
|
webView.isFocusableInTouchMode = true
|
|
39
64
|
webView.requestFocus()
|
|
40
|
-
|
|
65
|
+
|
|
66
|
+
// --- JS Bridge ---
|
|
67
|
+
androidBridge = AndroidBridge(this, webView, baseUrl)
|
|
68
|
+
webView.addJavascriptInterface(androidBridge, "Android")
|
|
69
|
+
|
|
70
|
+
// --- WebViewClient ---
|
|
41
71
|
webView.webViewClient = object : WebViewClient() {
|
|
72
|
+
|
|
42
73
|
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
|
|
43
|
-
if (url != null && url.startsWith(
|
|
44
|
-
|
|
74
|
+
return if (url != null && url.startsWith(baseUrl)) {
|
|
75
|
+
false
|
|
76
|
+
} else {
|
|
77
|
+
Toast.makeText(
|
|
78
|
+
this@MainActivity,
|
|
79
|
+
"Blocked external navigation",
|
|
80
|
+
Toast.LENGTH_SHORT
|
|
81
|
+
).show()
|
|
82
|
+
true
|
|
45
83
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
override fun onPageFinished(view: WebView?, url: String?) {
|
|
87
|
+
view?.evaluateJavascript(
|
|
88
|
+
"console.log('Android bridge ready:', !!window.Android)",
|
|
89
|
+
null
|
|
90
|
+
)
|
|
49
91
|
}
|
|
50
92
|
}
|
|
51
93
|
|
|
52
|
-
// ---
|
|
94
|
+
// --- Block popups ---
|
|
53
95
|
webView.webChromeClient = object : WebChromeClient() {
|
|
54
96
|
override fun onCreateWindow(
|
|
55
97
|
view: WebView?,
|
|
56
98
|
isDialog: Boolean,
|
|
57
99
|
isUserGesture: Boolean,
|
|
58
100
|
resultMsg: Message?
|
|
59
|
-
): Boolean
|
|
60
|
-
// Block popups completely
|
|
61
|
-
return false
|
|
62
|
-
}
|
|
101
|
+
): Boolean = false
|
|
63
102
|
}
|
|
64
103
|
|
|
65
|
-
// ---
|
|
66
|
-
webView.addJavascriptInterface(AndroidBridge(this, webView, baseUrl), "Android")
|
|
104
|
+
// --- Back button → JS ---
|
|
67
105
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
|
68
106
|
override fun handleOnBackPressed() {
|
|
69
|
-
// Forward back key to JS
|
|
70
107
|
webView.evaluateJavascript(
|
|
71
|
-
"
|
|
108
|
+
"window.onNativeKey && window.onNativeKey(4)",
|
|
72
109
|
null
|
|
73
110
|
)
|
|
74
|
-
// Do NOT call super, JS will handle closing modals/player
|
|
75
111
|
}
|
|
76
112
|
})
|
|
77
|
-
|
|
78
|
-
override fun onPageFinished(view: WebView?, url: String?) {
|
|
79
|
-
view?.evaluateJavascript("console.log('JS ready')", null)
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
// --- Load local HTML ---
|
|
113
|
+
|
|
83
114
|
webView.loadUrl(baseUrl)
|
|
84
115
|
}
|
|
85
116
|
|
|
117
|
+
// --- Permission result forwarding ---
|
|
118
|
+
override fun onRequestPermissionsResult(
|
|
119
|
+
requestCode: Int,
|
|
120
|
+
permissions: Array<out String>,
|
|
121
|
+
grantResults: IntArray
|
|
122
|
+
) {
|
|
123
|
+
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
|
124
|
+
androidBridge.onPermissionResult(requestCode, grantResults)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- DPAD / media keys ---
|
|
86
128
|
@SuppressLint("RestrictedApi")
|
|
87
129
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
|
88
130
|
if (event.action == KeyEvent.ACTION_DOWN) {
|
|
@@ -102,7 +144,6 @@ class MainActivity : ComponentActivity() {
|
|
|
102
144
|
}
|
|
103
145
|
true
|
|
104
146
|
}
|
|
105
|
-
|
|
106
147
|
else -> false
|
|
107
148
|
}
|
|
108
149
|
if (handled) return true
|
|
@@ -111,13 +152,164 @@ class MainActivity : ComponentActivity() {
|
|
|
111
152
|
}
|
|
112
153
|
}
|
|
113
154
|
|
|
114
|
-
|
|
155
|
+
// ---------------- JS BRIDGE ----------------
|
|
156
|
+
|
|
157
|
+
class AndroidBridge(
|
|
158
|
+
private val activity: ComponentActivity,
|
|
159
|
+
private val webView: WebView,
|
|
160
|
+
private val baseUrl: String
|
|
161
|
+
) {
|
|
162
|
+
|
|
163
|
+
private val PERMISSION_REQUEST_CODE = 9001
|
|
115
164
|
|
|
165
|
+
// ---- Toast ----
|
|
116
166
|
@JavascriptInterface
|
|
117
167
|
fun showToast(message: String) {
|
|
118
|
-
|
|
168
|
+
activity.runOnUiThread {
|
|
169
|
+
Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()
|
|
170
|
+
}
|
|
119
171
|
}
|
|
120
172
|
|
|
173
|
+
// ---- File System Methods ----
|
|
174
|
+
@JavascriptInterface
|
|
175
|
+
fun writeFile(path: String, content: String): Boolean {
|
|
176
|
+
return try {
|
|
177
|
+
// Create directories if needed
|
|
178
|
+
val file = File(activity.filesDir, path)
|
|
179
|
+
file.parentFile?.mkdirs()
|
|
180
|
+
|
|
181
|
+
file.bufferedWriter().use { writer ->
|
|
182
|
+
writer.write(content)
|
|
183
|
+
}
|
|
184
|
+
true
|
|
185
|
+
} catch (e: Exception) {
|
|
186
|
+
e.printStackTrace()
|
|
187
|
+
false
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@JavascriptInterface
|
|
192
|
+
fun readFile(path: String): String {
|
|
193
|
+
return try {
|
|
194
|
+
val file = File(activity.filesDir, path)
|
|
195
|
+
if (!file.exists()) {
|
|
196
|
+
return "{\"error\":\"File not found\"}"
|
|
197
|
+
}
|
|
198
|
+
file.bufferedReader().use { it.readText() }
|
|
199
|
+
} catch (e: Exception) {
|
|
200
|
+
e.printStackTrace()
|
|
201
|
+
"{\"error\":\"${e.message}\"}"
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@JavascriptInterface
|
|
206
|
+
fun deleteFile(path: String): Boolean {
|
|
207
|
+
return try {
|
|
208
|
+
val file = File(activity.filesDir, path)
|
|
209
|
+
file.delete()
|
|
210
|
+
} catch (e: Exception) {
|
|
211
|
+
e.printStackTrace()
|
|
212
|
+
false
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
@JavascriptInterface
|
|
217
|
+
fun listFiles(path: String = ""): String {
|
|
218
|
+
return try {
|
|
219
|
+
val dir = File(activity.filesDir, path)
|
|
220
|
+
val files = if (dir.exists() && dir.isDirectory) {
|
|
221
|
+
dir.list()?.toList() ?: emptyList()
|
|
222
|
+
} else {
|
|
223
|
+
emptyList()
|
|
224
|
+
}
|
|
225
|
+
JSONArray(files).toString()
|
|
226
|
+
} catch (e: Exception) {
|
|
227
|
+
e.printStackTrace()
|
|
228
|
+
"[]"
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---- Permissions ----
|
|
233
|
+
@JavascriptInterface
|
|
234
|
+
fun hasPermission(name: String): Boolean {
|
|
235
|
+
val permissions = mapPermission(name)
|
|
236
|
+
return permissions.all {
|
|
237
|
+
ContextCompat.checkSelfPermission(activity, it) ==
|
|
238
|
+
PackageManager.PERMISSION_GRANTED
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
@JavascriptInterface
|
|
243
|
+
fun requestPermission(name: String) {
|
|
244
|
+
val permissions = mapPermission(name)
|
|
245
|
+
|
|
246
|
+
if (permissions.isEmpty()) {
|
|
247
|
+
sendPermissionResult(true)
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
val granted = permissions.all {
|
|
252
|
+
ContextCompat.checkSelfPermission(activity, it) ==
|
|
253
|
+
PackageManager.PERMISSION_GRANTED
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (granted) {
|
|
257
|
+
sendPermissionResult(true)
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
ActivityCompat.requestPermissions(
|
|
262
|
+
activity,
|
|
263
|
+
permissions,
|
|
264
|
+
PERMISSION_REQUEST_CODE
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
fun onPermissionResult(requestCode: Int, grantResults: IntArray) {
|
|
269
|
+
if (requestCode != PERMISSION_REQUEST_CODE) return
|
|
270
|
+
val granted = grantResults.all { it == PackageManager.PERMISSION_GRANTED }
|
|
271
|
+
sendPermissionResult(granted)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private fun sendPermissionResult(granted: Boolean) {
|
|
275
|
+
webView.post {
|
|
276
|
+
webView.evaluateJavascript(
|
|
277
|
+
"window.onNativePermissionResult && window.onNativePermissionResult($granted)",
|
|
278
|
+
null
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---- Dialog ----
|
|
284
|
+
@JavascriptInterface
|
|
285
|
+
fun showDialog(
|
|
286
|
+
title: String,
|
|
287
|
+
message: String,
|
|
288
|
+
okText: String = "OK",
|
|
289
|
+
cancelText: String = "Cancel"
|
|
290
|
+
) {
|
|
291
|
+
activity.runOnUiThread {
|
|
292
|
+
AlertDialog.Builder(activity)
|
|
293
|
+
.setTitle(title)
|
|
294
|
+
.setMessage(message)
|
|
295
|
+
.setPositiveButton(okText) { _, _ ->
|
|
296
|
+
webView.evaluateJavascript(
|
|
297
|
+
"window.onNativeDialogResult && window.onNativeDialogResult(true)",
|
|
298
|
+
null
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
.setNegativeButton(cancelText) { _, _ ->
|
|
302
|
+
webView.evaluateJavascript(
|
|
303
|
+
"window.onNativeDialogResult && window.onNativeDialogResult(false)",
|
|
304
|
+
null
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
.setCancelable(false)
|
|
308
|
+
.show()
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---- Native fetch ----
|
|
121
313
|
@JavascriptInterface
|
|
122
314
|
fun nativeFetch(url: String, method: String): String {
|
|
123
315
|
return try {
|
|
@@ -125,26 +317,35 @@ class MainActivity : ComponentActivity() {
|
|
|
125
317
|
connection.requestMethod = method
|
|
126
318
|
connection.connectTimeout = 5000
|
|
127
319
|
connection.readTimeout = 5000
|
|
128
|
-
|
|
129
|
-
val responseText = connection.inputStream.bufferedReader().use { it.readText() }
|
|
320
|
+
val response = connection.inputStream.bufferedReader().use { it.readText() }
|
|
130
321
|
connection.disconnect()
|
|
131
|
-
|
|
322
|
+
response
|
|
132
323
|
} catch (e: Exception) {
|
|
133
|
-
"{\"error\"
|
|
324
|
+
"{\"error\":\"${e.message}\"}"
|
|
134
325
|
}
|
|
135
326
|
}
|
|
136
327
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
328
|
+
// ---- Navigation ----
|
|
329
|
+
@JavascriptInterface
|
|
330
|
+
fun navigate(path: String?) {
|
|
331
|
+
val clean = path?.trimStart('/') ?: ""
|
|
332
|
+
webView.post {
|
|
333
|
+
val finalUrl = "$baseUrl$clean/index.html".replace("//index", "/index")
|
|
334
|
+
webView.loadUrl(finalUrl)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
141
337
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
338
|
+
// ---- Permission map ----
|
|
339
|
+
private fun mapPermission(name: String): Array<String> {
|
|
340
|
+
return when (name) {
|
|
341
|
+
"storage" -> arrayOf(
|
|
342
|
+
android.Manifest.permission.READ_EXTERNAL_STORAGE,
|
|
343
|
+
android.Manifest.permission.WRITE_EXTERNAL_STORAGE
|
|
344
|
+
)
|
|
345
|
+
"camera" -> arrayOf(android.Manifest.permission.CAMERA)
|
|
346
|
+
"microphone" -> arrayOf(android.Manifest.permission.RECORD_AUDIO)
|
|
347
|
+
"notifications" -> arrayOf(android.Manifest.permission.POST_NOTIFICATIONS)
|
|
348
|
+
else -> emptyArray()
|
|
149
349
|
}
|
|
150
|
-
}
|
|
350
|
+
}
|
|
351
|
+
}
|
package/index.ts
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
;(window as any).onNativeDialogResult = function (confirmed: boolean) {
|
|
2
|
+
if (dialogResolver) {
|
|
3
|
+
dialogResolver(confirmed);
|
|
4
|
+
dialogResolver = null;
|
|
5
|
+
}
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Called by Android
|
|
10
|
+
*/
|
|
11
|
+
;(window as any).onNativePermissionResult = function (granted: boolean) {
|
|
12
|
+
if (permissionResolver) {
|
|
13
|
+
permissionResolver(granted);
|
|
14
|
+
permissionResolver = null;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
2
17
|
const ANDROID_KEY_MAP: Record<number, string> = {
|
|
3
18
|
19: "ArrowUp", // DPAD_UP
|
|
4
19
|
20: "ArrowDown", // DPAD_DOWN
|
|
@@ -970,16 +985,335 @@ export function Show({ when, children }: { when: boolean, children: VNode[] }):
|
|
|
970
985
|
//@ts-ignore
|
|
971
986
|
return when ? children : null;
|
|
972
987
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
988
|
+
/**
|
|
989
|
+
* @description Show toast allows you to invoke system level toast api to show data to user
|
|
990
|
+
* @param message
|
|
991
|
+
* @param duration
|
|
992
|
+
*/
|
|
993
|
+
export function showToast(message: string, duration = 3000) {
|
|
994
|
+
if (typeof window !== "undefined" && (window as any).Android?.showToast) {
|
|
995
|
+
console.log("[Vader] Android Toast");
|
|
996
|
+
(window as any).Android.showToast(message);
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Web fallback
|
|
1001
|
+
console.log("[Toast]", message);
|
|
1002
|
+
|
|
1003
|
+
const toast = document.createElement("div");
|
|
1004
|
+
toast.textContent = message;
|
|
1005
|
+
Object.assign(toast.style, {
|
|
1006
|
+
position: "fixed",
|
|
1007
|
+
bottom: "24px",
|
|
1008
|
+
left: "50%",
|
|
1009
|
+
transform: "translateX(-50%)",
|
|
1010
|
+
background: "rgba(0,0,0,0.85)",
|
|
1011
|
+
color: "white",
|
|
1012
|
+
padding: "10px 14px",
|
|
1013
|
+
borderRadius: "8px",
|
|
1014
|
+
zIndex: 9999,
|
|
1015
|
+
fontSize: "14px",
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
document.body.appendChild(toast);
|
|
1019
|
+
setTimeout(() => toast.remove(), duration);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
type PermissionName =
|
|
1023
|
+
| "storage"
|
|
1024
|
+
| "internet"
|
|
1025
|
+
| "camera"
|
|
1026
|
+
| "microphone"
|
|
1027
|
+
| "notifications";
|
|
1028
|
+
|
|
1029
|
+
let permissionResolver: ((granted: boolean) => void) | null = null;
|
|
1030
|
+
|
|
1031
|
+
export function usePermission() {
|
|
1032
|
+
const isAndroid =
|
|
1033
|
+
typeof window !== "undefined" &&
|
|
1034
|
+
(window as any).Android?.requestPermission;
|
|
1035
|
+
|
|
1036
|
+
function request(name: PermissionName): Promise<boolean> {
|
|
1037
|
+
if (isAndroid) {
|
|
1038
|
+
return new Promise<boolean>((resolve) => {
|
|
1039
|
+
permissionResolver = resolve;
|
|
1040
|
+
(window as any).Android.requestPermission(name);
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// ---- Web fallback ----
|
|
1045
|
+
console.warn(`[Permission] ${name} auto-granted on web`);
|
|
1046
|
+
return Promise.resolve(true);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function has(name: PermissionName): Promise<boolean> {
|
|
1050
|
+
if (isAndroid && (window as any).Android.hasPermission) {
|
|
1051
|
+
return Promise.resolve(
|
|
1052
|
+
(window as any).Android.hasPermission(name)
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
return Promise.resolve(true);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
return {
|
|
1059
|
+
request,
|
|
1060
|
+
has,
|
|
1061
|
+
|
|
1062
|
+
// ergonomic helpers
|
|
1063
|
+
storage: () => request("storage"),
|
|
1064
|
+
camera: () => request("camera"),
|
|
1065
|
+
microphone: () => request("microphone"),
|
|
1066
|
+
notifications: () => request("notifications"),
|
|
1067
|
+
internet: () => request("internet")
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
type FS = {
|
|
1072
|
+
readFile(path: string): Promise<string>
|
|
1073
|
+
writeFile(path: string, content: string): Promise<boolean>
|
|
1074
|
+
deleteFile(path: string): Promise<boolean>
|
|
1075
|
+
listDir(path: string): Promise<string[]>
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
export const FS: FS = {
|
|
1079
|
+
async writeFile(path: string, content: string): Promise<boolean> {
|
|
1080
|
+
try {
|
|
1081
|
+
if (!window.Android) {
|
|
1082
|
+
console.error('Android bridge not available')
|
|
1083
|
+
return false
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Call Android bridge method
|
|
1087
|
+
const result = window.Android.writeFile(path, content)
|
|
1088
|
+
return result === true || result === 'true'
|
|
1089
|
+
} catch (error) {
|
|
1090
|
+
console.error('Error writing file:', error)
|
|
1091
|
+
return false
|
|
1092
|
+
}
|
|
1093
|
+
},
|
|
1094
|
+
|
|
1095
|
+
async readFile(path: string): Promise<string> {
|
|
1096
|
+
try {
|
|
1097
|
+
if (!window.Android) {
|
|
1098
|
+
return JSON.stringify({ error: 'Android bridge not available' })
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const result = window.Android.readFile(path)
|
|
1102
|
+
|
|
1103
|
+
// Handle both string and boolean returns
|
|
1104
|
+
if (typeof result === 'boolean') {
|
|
1105
|
+
return result ? 'true' : 'false'
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
return result || ''
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
console.error('Error reading file:', error)
|
|
1111
|
+
return JSON.stringify({ error: error.message })
|
|
1112
|
+
}
|
|
1113
|
+
},
|
|
1114
|
+
|
|
1115
|
+
async deleteFile(path: string): Promise<boolean> {
|
|
1116
|
+
try {
|
|
1117
|
+
if (!window.Android) {
|
|
1118
|
+
console.error('Android bridge not available')
|
|
1119
|
+
return false
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Check if deleteFile method exists on Android bridge
|
|
1123
|
+
if (typeof window.Android.deleteFile === 'function') {
|
|
1124
|
+
const result = window.Android.deleteFile(path)
|
|
1125
|
+
return result === true || result === 'true'
|
|
1126
|
+
} else {
|
|
1127
|
+
// Fallback: Try to write empty content
|
|
1128
|
+
console.warn('deleteFile not available, using writeFile fallback')
|
|
1129
|
+
return await this.writeFile(path, '')
|
|
1130
|
+
}
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
console.error('Error deleting file:', error)
|
|
1133
|
+
return false
|
|
1134
|
+
}
|
|
1135
|
+
},
|
|
1136
|
+
|
|
1137
|
+
async listDir(path: string = ''): Promise<string[]> {
|
|
1138
|
+
try {
|
|
1139
|
+
if (!window.Android) {
|
|
1140
|
+
console.error('Android bridge not available')
|
|
1141
|
+
return []
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Check if listFiles method exists on Android bridge
|
|
1145
|
+
if (typeof window.Android.listFiles === 'function') {
|
|
1146
|
+
const result = window.Android.listFiles(path)
|
|
1147
|
+
|
|
1148
|
+
// Parse JSON array from string
|
|
1149
|
+
if (typeof result === 'string') {
|
|
1150
|
+
try {
|
|
1151
|
+
const parsed = JSON.parse(result)
|
|
1152
|
+
return Array.isArray(parsed) ? parsed : []
|
|
1153
|
+
} catch {
|
|
1154
|
+
// If not JSON, return as single item array or empty
|
|
1155
|
+
return result ? [result] : []
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// If result is already an array
|
|
1160
|
+
if (Array.isArray(result)) {
|
|
1161
|
+
return result
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
return []
|
|
1165
|
+
} else {
|
|
1166
|
+
console.warn('listFiles not available on Android bridge')
|
|
1167
|
+
return []
|
|
1168
|
+
}
|
|
1169
|
+
} catch (error) {
|
|
1170
|
+
console.error('Error listing directory:', error)
|
|
1171
|
+
return []
|
|
1172
|
+
}
|
|
1173
|
+
},
|
|
1174
|
+
|
|
1175
|
+
// Alias for backward compatibility
|
|
1176
|
+
write(path: string, data: string): Promise<boolean> {
|
|
1177
|
+
return this.writeFile(path, data)
|
|
1178
|
+
},
|
|
1179
|
+
|
|
1180
|
+
read(path: string): Promise<string> {
|
|
1181
|
+
return this.readFile(path)
|
|
980
1182
|
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// TypeScript declarations for Android bridge
|
|
1186
|
+
declare global {
|
|
1187
|
+
interface Window {
|
|
1188
|
+
Android?: {
|
|
1189
|
+
writeFile?: (path: string, content: string) => boolean | string
|
|
1190
|
+
readFile?: (path: string) => string
|
|
1191
|
+
deleteFile?: (path: string) => boolean | string
|
|
1192
|
+
listFiles?: (path?: string) => string[] | string
|
|
1193
|
+
// Other Android bridge methods...
|
|
1194
|
+
showToast?: (message: string) => void
|
|
1195
|
+
hasPermission?: (name: string) => boolean
|
|
1196
|
+
requestPermission?: (name: string) => void
|
|
1197
|
+
showDialog?: (title: string, message: string, okText?: string, cancelText?: string) => void
|
|
1198
|
+
nativeFetch?: (url: string, method: string) => string
|
|
1199
|
+
navigate?: (path: string) => void
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Utility functions for common operations
|
|
1205
|
+
export const FileSystem = {
|
|
1206
|
+
// Save JSON data
|
|
1207
|
+
async saveJSON(path: string, data: any): Promise<boolean> {
|
|
1208
|
+
return await FS.writeFile(path, JSON.stringify(data, null, 2))
|
|
1209
|
+
},
|
|
1210
|
+
|
|
1211
|
+
// Load JSON data
|
|
1212
|
+
async loadJSON<T = any>(path: string): Promise<T | null> {
|
|
1213
|
+
try {
|
|
1214
|
+
const content = await FS.readFile(path)
|
|
1215
|
+
if (!content || content.includes('error')) {
|
|
1216
|
+
return null
|
|
1217
|
+
}
|
|
1218
|
+
return JSON.parse(content)
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
console.error('Error parsing JSON:', error)
|
|
1221
|
+
return null
|
|
1222
|
+
}
|
|
1223
|
+
},
|
|
1224
|
+
|
|
1225
|
+
// Check if file exists
|
|
1226
|
+
async exists(path: string): Promise<boolean> {
|
|
1227
|
+
try {
|
|
1228
|
+
const content = await FS.readFile(path)
|
|
1229
|
+
return !content.includes('File not found') && !content.includes('error')
|
|
1230
|
+
} catch {
|
|
1231
|
+
return false
|
|
1232
|
+
}
|
|
1233
|
+
},
|
|
1234
|
+
|
|
1235
|
+
// Append to file
|
|
1236
|
+
async appendFile(path: string, content: string): Promise<boolean> {
|
|
1237
|
+
try {
|
|
1238
|
+
const existing = await FS.readFile(path)
|
|
1239
|
+
const newContent = existing + content
|
|
1240
|
+
return await FS.writeFile(path, newContent)
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
console.error('Error appending to file:', error)
|
|
1243
|
+
return false
|
|
1244
|
+
}
|
|
1245
|
+
},
|
|
1246
|
+
|
|
1247
|
+
// Create directory (by creating a dummy file)
|
|
1248
|
+
async createDirectory(path: string): Promise<boolean> {
|
|
1249
|
+
// Create a .nomedia file in the directory
|
|
1250
|
+
const dirPath = path.endsWith('/') ? path : path + '/'
|
|
1251
|
+
return await FS.writeFile(dirPath + '.nomedia', '')
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
type DialogOptions = {
|
|
1255
|
+
title?: string;
|
|
1256
|
+
message: string;
|
|
1257
|
+
okText?: string;
|
|
1258
|
+
cancelText?: string;
|
|
981
1259
|
};
|
|
982
1260
|
|
|
1261
|
+
let dialogResolver: ((value: boolean) => void) | null = null;
|
|
1262
|
+
|
|
1263
|
+
export function useDialog() {
|
|
1264
|
+
// ---- ANDROID IMPLEMENTATION ----
|
|
1265
|
+
if (typeof window !== "undefined" && (window as any).Android?.showDialog) {
|
|
1266
|
+
return {
|
|
1267
|
+
alert({ title = "", message, okText = "OK" }: DialogOptions) {
|
|
1268
|
+
return new Promise<void>((resolve) => {
|
|
1269
|
+
dialogResolver = () => resolve();
|
|
1270
|
+
|
|
1271
|
+
(window as any).Android.showDialog(
|
|
1272
|
+
title,
|
|
1273
|
+
message,
|
|
1274
|
+
okText,
|
|
1275
|
+
"" // no cancel
|
|
1276
|
+
);
|
|
1277
|
+
});
|
|
1278
|
+
},
|
|
1279
|
+
|
|
1280
|
+
confirm({
|
|
1281
|
+
title = "",
|
|
1282
|
+
message,
|
|
1283
|
+
okText = "OK",
|
|
1284
|
+
cancelText = "Cancel",
|
|
1285
|
+
}: DialogOptions) {
|
|
1286
|
+
return new Promise<boolean>((resolve) => {
|
|
1287
|
+
dialogResolver = resolve;
|
|
1288
|
+
|
|
1289
|
+
(window as any).Android.showDialog(
|
|
1290
|
+
title,
|
|
1291
|
+
message,
|
|
1292
|
+
okText,
|
|
1293
|
+
cancelText
|
|
1294
|
+
);
|
|
1295
|
+
});
|
|
1296
|
+
},
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// ---- WEB FALLBACK ----
|
|
1301
|
+
return {
|
|
1302
|
+
alert({ title = "", message }: DialogOptions) {
|
|
1303
|
+
window.alert(title ? `${title}\n\n${message}` : message);
|
|
1304
|
+
return Promise.resolve();
|
|
1305
|
+
},
|
|
1306
|
+
|
|
1307
|
+
confirm({ title = "", message }: DialogOptions) {
|
|
1308
|
+
const result = window.confirm(
|
|
1309
|
+
title ? `${title}\n\n${message}` : message
|
|
1310
|
+
);
|
|
1311
|
+
return Promise.resolve(result);
|
|
1312
|
+
},
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
|
|
983
1317
|
/**
|
|
984
1318
|
* A React-like useRef hook for mutable references.
|
|
985
1319
|
* @template T
|