Compare commits
23 Commits
android-fi
...
address-du
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
944f5bc224 | ||
|
|
4d9435f257 | ||
|
|
a353ed3c3e | ||
| e6cc058935 | |||
|
|
37cff0083f | ||
| 2049c9b6ec | |||
|
|
f186e129db | ||
|
|
455dfadb92 | ||
| 637fc10e64 | |||
| 37d4dcc1a8 | |||
| c369c76c1a | |||
| 86caf793aa | |||
| 499fbd2cb3 | |||
| a4a9293bc2 | |||
| 9ac9f1d4a3 | |||
|
|
fface30123 | ||
| 97b382451a | |||
|
|
7fd2c4e0c7 | ||
|
|
20322789a2 | ||
|
|
666bed0efd | ||
|
|
7432525f4c | ||
| 530cddfab0 | |||
| 5340c00ae2 |
@@ -2,7 +2,7 @@
|
|||||||
globs: **/src/**/*
|
globs: **/src/**/*
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
✅ use system date command to timestamp all interactions with accurate date and
|
✅ use system date command to timestamp all documentation with accurate date and
|
||||||
time
|
time
|
||||||
✅ remove whitespace at the end of lines
|
✅ remove whitespace at the end of lines
|
||||||
✅ use npm run lint-fix to check for warnings
|
✅ use npm run lint-fix to check for warnings
|
||||||
|
|||||||
@@ -1,251 +0,0 @@
|
|||||||
# Android File Saver Plugin Implementation Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document outlines the implementation of the `AndroidFileSaver` Capacitor plugin that provides Storage Access Framework (SAF) and MediaStore functionality for direct file saving on Android devices.
|
|
||||||
|
|
||||||
## Plugin Purpose
|
|
||||||
|
|
||||||
The `AndroidFileSaver` plugin enables two key file operations:
|
|
||||||
1. **`saveToDownloads`**: Direct save to Downloads folder using MediaStore (API 29+)
|
|
||||||
2. **`saveAs`**: User-chosen location using Storage Access Framework (SAF)
|
|
||||||
|
|
||||||
## Implementation Requirements
|
|
||||||
|
|
||||||
### 1. Plugin Structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Plugin interface
|
|
||||||
interface AndroidFileSaverPlugin {
|
|
||||||
saveToDownloads(options: {
|
|
||||||
fileName: string;
|
|
||||||
content: string;
|
|
||||||
mimeType: string
|
|
||||||
}): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
path?: string;
|
|
||||||
error?: string
|
|
||||||
}>;
|
|
||||||
|
|
||||||
saveAs(options: {
|
|
||||||
fileName: string;
|
|
||||||
content: string;
|
|
||||||
mimeType: string
|
|
||||||
}): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
path?: string;
|
|
||||||
error?: string
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Android Implementation
|
|
||||||
|
|
||||||
#### MediaStore for Downloads (API 29+)
|
|
||||||
|
|
||||||
```java
|
|
||||||
@PluginMethod
|
|
||||||
public void saveToDownloads(PluginCall call) {
|
|
||||||
String fileName = call.getString("fileName");
|
|
||||||
String content = call.getString("content");
|
|
||||||
String mimeType = call.getString("mimeType");
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
// Use MediaStore for Downloads
|
|
||||||
ContentValues values = new ContentValues();
|
|
||||||
values.put(MediaStore.Downloads.DISPLAY_NAME, fileName);
|
|
||||||
values.put(MediaStore.Downloads.MIME_TYPE, mimeType);
|
|
||||||
values.put(MediaStore.Downloads.IS_PENDING, 1);
|
|
||||||
|
|
||||||
ContentResolver resolver = getContext().getContentResolver();
|
|
||||||
Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
|
|
||||||
|
|
||||||
if (uri != null) {
|
|
||||||
try (ParcelFileDescriptor pfd = resolver.openFileDescriptor(uri, "w")) {
|
|
||||||
if (pfd != null) {
|
|
||||||
try (FileOutputStream fos = new FileOutputStream(pfd.getFileDescriptor())) {
|
|
||||||
fos.write(content.getBytes());
|
|
||||||
fos.flush();
|
|
||||||
|
|
||||||
// Mark as no longer pending
|
|
||||||
values.clear();
|
|
||||||
values.put(MediaStore.Downloads.IS_PENDING, 0);
|
|
||||||
resolver.update(uri, values, null, null);
|
|
||||||
|
|
||||||
call.resolve(new JSObject()
|
|
||||||
.put("success", true)
|
|
||||||
.put("path", uri.toString()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
resolver.delete(uri, null, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
call.resolve(new JSObject()
|
|
||||||
.put("success", false)
|
|
||||||
.put("error", "Failed to save file"));
|
|
||||||
} else {
|
|
||||||
// Fallback for older Android versions
|
|
||||||
call.resolve(new JSObject()
|
|
||||||
.put("success", false)
|
|
||||||
.put("error", "Requires Android API 29+"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Storage Access Framework (SAF) for Save As
|
|
||||||
|
|
||||||
```java
|
|
||||||
@PluginMethod
|
|
||||||
public void saveAs(PluginCall call) {
|
|
||||||
String fileName = call.getString("fileName");
|
|
||||||
String content = call.getString("content");
|
|
||||||
String mimeType = call.getString("mimeType");
|
|
||||||
|
|
||||||
// Store call for later use
|
|
||||||
bridge.saveCall(call);
|
|
||||||
|
|
||||||
// Create intent for SAF
|
|
||||||
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
|
|
||||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
|
||||||
intent.setType(mimeType);
|
|
||||||
intent.putExtra(Intent.EXTRA_TITLE, fileName);
|
|
||||||
|
|
||||||
// Start activity for result
|
|
||||||
bridge.startActivityForResult(call, intent, "saveAsResult");
|
|
||||||
}
|
|
||||||
|
|
||||||
@ActivityCallback
|
|
||||||
private void saveAsResult(PluginCall call, ActivityResult result) {
|
|
||||||
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
|
|
||||||
Uri uri = result.getData().getData();
|
|
||||||
String content = call.getString("content");
|
|
||||||
|
|
||||||
try (ParcelFileDescriptor pfd = getContext().getContentResolver()
|
|
||||||
.openFileDescriptor(uri, "w")) {
|
|
||||||
if (pfd != null) {
|
|
||||||
try (FileOutputStream fos = new FileOutputStream(pfd.getFileDescriptor())) {
|
|
||||||
fos.write(content.getBytes());
|
|
||||||
fos.flush();
|
|
||||||
|
|
||||||
call.resolve(new JSObject()
|
|
||||||
.put("success", true)
|
|
||||||
.put("path", uri.toString()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
|
|
||||||
call.resolve(new JSObject()
|
|
||||||
.put("success", false)
|
|
||||||
.put("error", "Failed to save file"));
|
|
||||||
} else {
|
|
||||||
call.resolve(new JSObject()
|
|
||||||
.put("success", false)
|
|
||||||
.put("error", "User cancelled or failed"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Plugin Registration
|
|
||||||
|
|
||||||
```java
|
|
||||||
@CapacitorPlugin(name = "AndroidFileSaver")
|
|
||||||
public class AndroidFileSaverPlugin extends Plugin {
|
|
||||||
// Implementation methods here
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration Steps
|
|
||||||
|
|
||||||
### 1. Create Plugin Project
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create new Capacitor plugin
|
|
||||||
npx @capacitor/cli plugin:generate android-file-saver
|
|
||||||
cd android-file-saver
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Add to Android Project
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# In your main project
|
|
||||||
npm install ./android-file-saver
|
|
||||||
npx cap sync android
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Update Android Manifest
|
|
||||||
|
|
||||||
Ensure the following permissions are present:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
|
||||||
android:maxSdkVersion="28" />
|
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
|
||||||
android:maxSdkVersion="32" />
|
|
||||||
```
|
|
||||||
|
|
||||||
## Fallback Behavior
|
|
||||||
|
|
||||||
When the plugin is not available, the system falls back to:
|
|
||||||
1. **Web**: Browser download mechanism
|
|
||||||
2. **Electron**: Native file save via IPC
|
|
||||||
3. **Capacitor**: Share dialog (existing behavior)
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### 1. Plugin Availability
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Check if plugin is available
|
|
||||||
if (AndroidFileSaver) {
|
|
||||||
// Plugin available - use native methods
|
|
||||||
} else {
|
|
||||||
// Plugin not available - use fallback
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Error Handling
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
try {
|
|
||||||
const result = await AndroidFileSaver.saveToDownloads({
|
|
||||||
fileName: "test.json",
|
|
||||||
content: '{"test": "data"}',
|
|
||||||
mimeType: "application/json"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
logger.info("File saved:", result.path);
|
|
||||||
} else {
|
|
||||||
logger.error("Save failed:", result.error);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Plugin error:", error);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
1. **Content Validation**: Validate file content before saving
|
|
||||||
2. **MIME Type Verification**: Ensure MIME type matches file content
|
|
||||||
3. **Permission Handling**: Request storage permissions appropriately
|
|
||||||
4. **Error Logging**: Log errors without exposing sensitive data
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
1. **Progress Callbacks**: Add progress reporting for large files
|
|
||||||
2. **Batch Operations**: Support saving multiple files
|
|
||||||
3. **Custom Locations**: Allow saving to app-specific directories
|
|
||||||
4. **File Compression**: Add optional file compression
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Android Storage Access Framework](https://developer.android.com/guide/topics/providers/document-provider)
|
|
||||||
- [MediaStore Downloads](https://developer.android.com/reference/android/provider/MediaStore.Downloads)
|
|
||||||
- [Capacitor Plugin Development](https://capacitorjs.com/docs/plugins/android)
|
|
||||||
- [Android Scoped Storage](https://developer.android.com/training/data-storage)
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "timesafari",
|
"name": "timesafari",
|
||||||
"version": "1.1.0-beta",
|
"version": "1.1.1-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "timesafari",
|
"name": "timesafari",
|
||||||
"version": "1.1.0-beta",
|
"version": "1.1.1-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor-community/electron": "^5.0.1",
|
"@capacitor-community/electron": "^5.0.1",
|
||||||
"@capacitor-community/sqlite": "6.0.2",
|
"@capacitor-community/sqlite": "6.0.2",
|
||||||
|
|||||||
@@ -77,12 +77,86 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Emoji Section -->
|
||||||
<p class="font-medium overflow-hidden">
|
<div
|
||||||
<a
|
v-if="hasEmojis || isRegistered"
|
||||||
class="block cursor-pointer overflow-hidden text-ellipsis"
|
class="float-right ml-3 mb-1 bg-white rounded border border-slate-300 px-1.5 py-0.5 max-w-[240px]"
|
||||||
@click="emitLoadClaim(record.jwtId)"
|
>
|
||||||
|
<div class="flex items-center justify-between gap-1">
|
||||||
|
<!-- Existing Emojis Display -->
|
||||||
|
<div v-if="hasEmojis" class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
v-for="(count, emoji) in record.emojiCount"
|
||||||
|
:key="emoji"
|
||||||
|
class="inline-flex items-center gap-0.5 px-1 py-0.5 text-xs bg-slate-50 hover:bg-slate-100 rounded border border-slate-200 transition-colors cursor-pointer"
|
||||||
|
:class="{
|
||||||
|
'bg-blue-50 border-blue-200': isUserEmojiWithoutLoading(emoji),
|
||||||
|
'opacity-75 cursor-wait': loadingEmojis,
|
||||||
|
}"
|
||||||
|
:title="
|
||||||
|
loadingEmojis
|
||||||
|
? 'Loading...'
|
||||||
|
: !emojisOnActivity?.isResolved
|
||||||
|
? 'Click to load your emojis'
|
||||||
|
: isUserEmojiWithoutLoading(emoji)
|
||||||
|
? 'Click to remove your emoji'
|
||||||
|
: 'Click to add this emoji'
|
||||||
|
"
|
||||||
|
:disabled="!isRegistered"
|
||||||
|
@click="toggleThisEmoji(emoji)"
|
||||||
|
>
|
||||||
|
<!-- Show spinner when loading -->
|
||||||
|
<div v-if="loadingEmojis" class="animate-spin text-xs">
|
||||||
|
<font-awesome icon="spinner" class="fa-spin" />
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-sm leading-none">{{ emoji }}</span>
|
||||||
|
<span class="text-xs text-slate-600 font-medium leading-none">{{
|
||||||
|
count
|
||||||
|
}}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Emoji Button -->
|
||||||
|
<button
|
||||||
|
v-if="isRegistered"
|
||||||
|
class="inline-flex px-1 py-0.5 text-xs bg-slate-100 hover:bg-slate-200 rounded border border-slate-300 transition-colors items-center justify-center ml-2 ml-auto"
|
||||||
|
:title="showEmojiPicker ? 'Close emoji picker' : 'Add emoji'"
|
||||||
|
@click="toggleEmojiPicker"
|
||||||
|
>
|
||||||
|
<span class="px-2 text-sm leading-none">{{
|
||||||
|
showEmojiPicker ? "x" : "😊"
|
||||||
|
}}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Emoji Picker (placeholder for now) -->
|
||||||
|
<div
|
||||||
|
v-if="showEmojiPicker"
|
||||||
|
class="mt-1 p-1.5 bg-slate-50 rounded border border-slate-300"
|
||||||
>
|
>
|
||||||
|
<!-- Temporary emoji buttons for testing -->
|
||||||
|
<div class="flex flex-wrap gap-3 mt-1">
|
||||||
|
<button
|
||||||
|
v-for="emoji in QUICK_EMOJIS"
|
||||||
|
:key="emoji"
|
||||||
|
class="p-0.5 hover:bg-slate-200 rounded text-base transition-opacity"
|
||||||
|
:class="{
|
||||||
|
'opacity-75 cursor-wait': loadingEmojis,
|
||||||
|
}"
|
||||||
|
:disabled="loadingEmojis"
|
||||||
|
@click="toggleThisEmoji(emoji)"
|
||||||
|
>
|
||||||
|
<!-- Show spinner when loading -->
|
||||||
|
<div v-if="loadingEmojis" class="animate-spin text-sm">⟳</div>
|
||||||
|
<span v-else>{{ emoji }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<p class="font-medium">
|
||||||
|
<a class="block cursor-pointer" @click="emitLoadClaim(record.jwtId)">
|
||||||
<vue-markdown
|
<vue-markdown
|
||||||
:source="truncatedDescription"
|
:source="truncatedDescription"
|
||||||
class="markdown-content"
|
class="markdown-content"
|
||||||
@@ -91,7 +165,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mt-4"
|
class="clear-right relative flex justify-between gap-4 max-w-[40rem] mx-auto mt-4"
|
||||||
>
|
>
|
||||||
<!-- Source -->
|
<!-- Source -->
|
||||||
<div
|
<div
|
||||||
@@ -254,17 +328,24 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||||
import { GiveRecordWithContactInfo } from "@/interfaces/give";
|
import VueMarkdown from "vue-markdown-render";
|
||||||
|
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import {
|
||||||
|
createAndSubmitClaim,
|
||||||
|
getHeaders,
|
||||||
|
isHiddenDid,
|
||||||
|
} from "../libs/endorserServer";
|
||||||
import EntityIcon from "./EntityIcon.vue";
|
import EntityIcon from "./EntityIcon.vue";
|
||||||
import { isHiddenDid } from "../libs/endorserServer";
|
|
||||||
import ProjectIcon from "./ProjectIcon.vue";
|
import ProjectIcon from "./ProjectIcon.vue";
|
||||||
import { createNotifyHelpers, NotifyFunction } from "@/utils/notify";
|
import { createNotifyHelpers, NotifyFunction, TIMEOUTS } from "@/utils/notify";
|
||||||
import {
|
import {
|
||||||
NOTIFY_PERSON_HIDDEN,
|
NOTIFY_PERSON_HIDDEN,
|
||||||
NOTIFY_UNKNOWN_PERSON,
|
NOTIFY_UNKNOWN_PERSON,
|
||||||
} from "@/constants/notifications";
|
} from "@/constants/notifications";
|
||||||
import { TIMEOUTS } from "@/utils/notify";
|
import { EmojiSummaryRecord, GenericVerifiableCredential } from "@/interfaces";
|
||||||
import VueMarkdown from "vue-markdown-render";
|
import { GiveRecordWithContactInfo } from "@/interfaces/give";
|
||||||
|
import { PromiseTracker } from "@/libs/util";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
@@ -274,15 +355,24 @@ import VueMarkdown from "vue-markdown-render";
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class ActivityListItem extends Vue {
|
export default class ActivityListItem extends Vue {
|
||||||
|
readonly QUICK_EMOJIS = ["👍", "👏", "❤️", "🎉", "😊", "😆", "🔥"];
|
||||||
|
|
||||||
@Prop() record!: GiveRecordWithContactInfo;
|
@Prop() record!: GiveRecordWithContactInfo;
|
||||||
@Prop() lastViewedClaimId?: string;
|
@Prop() lastViewedClaimId?: string;
|
||||||
@Prop() isRegistered!: boolean;
|
@Prop() isRegistered!: boolean;
|
||||||
@Prop() activeDid!: string;
|
@Prop() activeDid!: string;
|
||||||
|
@Prop() apiServer!: string;
|
||||||
|
|
||||||
isHiddenDid = isHiddenDid;
|
isHiddenDid = isHiddenDid;
|
||||||
notify!: ReturnType<typeof createNotifyHelpers>;
|
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||||
$notify!: NotifyFunction;
|
$notify!: NotifyFunction;
|
||||||
|
|
||||||
|
// Emoji-related data
|
||||||
|
showEmojiPicker = false;
|
||||||
|
loadingEmojis = false; // Track if emojis are currently loading
|
||||||
|
|
||||||
|
emojisOnActivity: PromiseTracker<EmojiSummaryRecord[]> | null = null; // load this only when needed
|
||||||
|
|
||||||
created() {
|
created() {
|
||||||
this.notify = createNotifyHelpers(this.$notify);
|
this.notify = createNotifyHelpers(this.$notify);
|
||||||
}
|
}
|
||||||
@@ -346,5 +436,186 @@ export default class ActivityListItem extends Vue {
|
|||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emoji-related computed properties and methods
|
||||||
|
get hasEmojis(): boolean {
|
||||||
|
return Object.keys(this.record.emojiCount).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerUserEmojiLoad(): PromiseTracker<EmojiSummaryRecord[]> {
|
||||||
|
if (!this.emojisOnActivity) {
|
||||||
|
const promise = new Promise<EmojiSummaryRecord[]>((resolve) => {
|
||||||
|
(async () => {
|
||||||
|
this.axios
|
||||||
|
.get(
|
||||||
|
`${this.apiServer}/api/v2/report/emoji?parentHandleId=${encodeURIComponent(this.record.handleId)}`,
|
||||||
|
{ headers: await getHeaders(this.activeDid) },
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
const userEmojiRecords = response.data.data.filter(
|
||||||
|
(e: EmojiSummaryRecord) => e.issuerDid === this.activeDid,
|
||||||
|
);
|
||||||
|
resolve(userEmojiRecords);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error("Error loading user emojis:", error);
|
||||||
|
resolve([]);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emojisOnActivity = new PromiseTracker(promise);
|
||||||
|
}
|
||||||
|
return this.emojisOnActivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param emoji - The emoji to check.
|
||||||
|
* @returns True if the emoji is in the user's emojis, false otherwise.
|
||||||
|
*
|
||||||
|
* @note This method is quick and synchronous, and can check resolved emojis
|
||||||
|
* without triggering a server request. Returns false if emojis haven't been loaded yet.
|
||||||
|
*/
|
||||||
|
isUserEmojiWithoutLoading(emoji: string): boolean {
|
||||||
|
if (this.emojisOnActivity?.isResolved && this.emojisOnActivity.value) {
|
||||||
|
return this.emojisOnActivity.value.some(
|
||||||
|
(record) => record.text === emoji,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleEmojiPicker() {
|
||||||
|
this.triggerUserEmojiLoad(); // trigger it, but don't wait for it to complete
|
||||||
|
this.showEmojiPicker = !this.showEmojiPicker;
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleThisEmoji(emoji: string) {
|
||||||
|
// Start loading indicator
|
||||||
|
this.loadingEmojis = true;
|
||||||
|
this.showEmojiPicker = false; // always close the picker when an emoji is clicked
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.triggerUserEmojiLoad(); // trigger just in case
|
||||||
|
|
||||||
|
const userEmojiList = await this.emojisOnActivity!.promise; // must wait now that they've chosen
|
||||||
|
|
||||||
|
const userHasEmoji: boolean = userEmojiList.some(
|
||||||
|
(record) => record.text === emoji,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userHasEmoji) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "modal",
|
||||||
|
type: "confirm",
|
||||||
|
title: "Remove Emoji",
|
||||||
|
text: `Do you want to remove your ${emoji} ?`,
|
||||||
|
yesText: "Remove",
|
||||||
|
onYes: async () => {
|
||||||
|
await this.removeEmoji(emoji);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TIMEOUTS.MODAL,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// User doesn't have this emoji, add it
|
||||||
|
await this.submitEmoji(emoji);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Remove loading indicator
|
||||||
|
this.loadingEmojis = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitEmoji(emoji: string) {
|
||||||
|
try {
|
||||||
|
// Create an Emoji claim and send to the server
|
||||||
|
const emojiClaim: GenericVerifiableCredential = {
|
||||||
|
"@context": "https://endorser.ch",
|
||||||
|
"@type": "Emoji",
|
||||||
|
text: emoji,
|
||||||
|
parentItem: { lastClaimId: this.record.jwtId },
|
||||||
|
};
|
||||||
|
const claim = await createAndSubmitClaim(
|
||||||
|
emojiClaim,
|
||||||
|
this.activeDid,
|
||||||
|
this.apiServer,
|
||||||
|
this.axios,
|
||||||
|
);
|
||||||
|
if (claim.success && !claim.embeddedRecordError) {
|
||||||
|
// Update emoji count
|
||||||
|
this.record.emojiCount[emoji] =
|
||||||
|
(this.record.emojiCount[emoji] || 0) + 1;
|
||||||
|
|
||||||
|
// Create a new emoji record (we'll get the actual jwtId from the server response later)
|
||||||
|
const newEmojiRecord: EmojiSummaryRecord = {
|
||||||
|
issuerDid: this.activeDid,
|
||||||
|
jwtId: claim.claimId || "",
|
||||||
|
text: emoji,
|
||||||
|
parentHandleId: this.record.jwtId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update user emojis list by creating a new promise with the updated data
|
||||||
|
// (Trigger shouldn't be necessary since all calls should come through a toggle, but just in case someone calls this directly)
|
||||||
|
this.triggerUserEmojiLoad();
|
||||||
|
const currentEmojis = await this.emojisOnActivity!.promise; // must wait now that they've clicked one
|
||||||
|
this.emojisOnActivity = new PromiseTracker(
|
||||||
|
Promise.resolve([...currentEmojis, newEmojiRecord]),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.notify.error("Failed to add emoji.", TIMEOUTS.STANDARD);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error submitting emoji:", error);
|
||||||
|
this.notify.error("Got error adding emoji.", TIMEOUTS.STANDARD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeEmoji(emoji: string) {
|
||||||
|
try {
|
||||||
|
// Create an Emoji claim and send to the server
|
||||||
|
const emojiClaim: GenericVerifiableCredential = {
|
||||||
|
"@context": "https://endorser.ch",
|
||||||
|
"@type": "Emoji",
|
||||||
|
text: emoji,
|
||||||
|
parentItem: { lastClaimId: this.record.jwtId },
|
||||||
|
};
|
||||||
|
const claim = await createAndSubmitClaim(
|
||||||
|
emojiClaim,
|
||||||
|
this.activeDid,
|
||||||
|
this.apiServer,
|
||||||
|
this.axios,
|
||||||
|
);
|
||||||
|
if (claim.success && !claim.embeddedRecordError) {
|
||||||
|
// Update emoji count
|
||||||
|
const newCount = Math.max(0, (this.record.emojiCount[emoji] || 0) - 1);
|
||||||
|
if (newCount === 0) {
|
||||||
|
delete this.record.emojiCount[emoji];
|
||||||
|
} else {
|
||||||
|
this.record.emojiCount[emoji] = newCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user emojis list by creating a new promise with the updated data
|
||||||
|
// (Trigger shouldn't be necessary since all calls should come through a toggle, but just in case someone calls this directly)
|
||||||
|
this.triggerUserEmojiLoad();
|
||||||
|
const currentEmojis = await this.emojisOnActivity!.promise; // must wait now that they've clicked one
|
||||||
|
this.emojisOnActivity = new PromiseTracker(
|
||||||
|
Promise.resolve(
|
||||||
|
currentEmojis.filter(
|
||||||
|
(record) =>
|
||||||
|
record.issuerDid === this.activeDid && record.text !== emoji,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.notify.error("Failed to remove emoji.", TIMEOUTS.STANDARD);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error removing emoji:", error);
|
||||||
|
this.notify.error("Got error removing emoji.", TIMEOUTS.STANDARD);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -25,46 +25,33 @@ messages * - Conditional UI based on platform capabilities * * @component *
|
|||||||
Backup Identifier Seed
|
Backup Identifier Seed
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 mt-2">
|
<button
|
||||||
<button
|
:disabled="isExporting"
|
||||||
:disabled="isExporting"
|
:class="exportButtonClasses"
|
||||||
:class="shareButtonClasses"
|
@click="exportDatabase()"
|
||||||
@click="shareContacts()"
|
>
|
||||||
>
|
{{ isExporting ? "Exporting..." : "Download Contacts" }}
|
||||||
{{ isExporting ? "Exporting..." : "Share Contacts" }}
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
:disabled="isExporting"
|
|
||||||
:class="saveButtonClasses"
|
|
||||||
@click="saveContactsToDevice()"
|
|
||||||
>
|
|
||||||
{{ isExporting ? "Exporting..." : "Save to Device" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="capabilities.needsFileHandlingInstructions"
|
v-if="capabilities.needsFileHandlingInstructions"
|
||||||
:class="instructionsContainerClasses"
|
:class="instructionsContainerClasses"
|
||||||
>
|
>
|
||||||
<p>Choose how you want to export your contacts:</p>
|
<p>
|
||||||
|
After the export, you can save the file in your preferred storage
|
||||||
|
location.
|
||||||
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li :class="listItemClasses">
|
|
||||||
<strong>Share Contacts:</strong> Opens the system share dialog to send
|
|
||||||
to apps like Gmail, Drive, or messaging apps.
|
|
||||||
</li>
|
|
||||||
<li :class="listItemClasses">
|
|
||||||
<strong>Save to Device:</strong> Saves directly to your device's
|
|
||||||
Downloads folder (Android) or Documents folder (iOS).
|
|
||||||
</li>
|
|
||||||
<li v-if="capabilities.isIOS" :class="listItemClasses">
|
<li v-if="capabilities.isIOS" :class="listItemClasses">
|
||||||
On iOS: Files are saved to the Files app in your Documents folder.
|
On iOS: You will be prompted to choose a location to save your backup
|
||||||
|
file.
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
v-if="capabilities.isMobile && !capabilities.isIOS"
|
v-if="capabilities.isMobile && !capabilities.isIOS"
|
||||||
:class="listItemClasses"
|
:class="listItemClasses"
|
||||||
>
|
>
|
||||||
On Android: Files are saved directly to your Downloads folder.
|
On Android: You will be prompted to choose a location to save your
|
||||||
|
backup file.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,20 +151,6 @@ export default class DataExportSection extends Vue {
|
|||||||
return "block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed";
|
return "block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* CSS classes for the share button
|
|
||||||
*/
|
|
||||||
get shareButtonClasses(): string {
|
|
||||||
return "block w-full text-center text-md bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CSS classes for the save to device button
|
|
||||||
*/
|
|
||||||
get saveButtonClasses(): string {
|
|
||||||
return "block w-full text-center text-md bg-gradient-to-b from-purple-400 to-purple-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS classes for the instructions container
|
* CSS classes for the instructions container
|
||||||
*/
|
*/
|
||||||
@@ -255,100 +228,6 @@ export default class DataExportSection extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Shares contacts data using the platform's share functionality
|
|
||||||
* Opens the system share dialog for app-to-app handoff
|
|
||||||
*
|
|
||||||
* @throws {Error} If sharing fails
|
|
||||||
*/
|
|
||||||
public async shareContacts(): Promise<void> {
|
|
||||||
if (this.isExporting) {
|
|
||||||
return; // Prevent multiple simultaneous exports
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.isExporting = true;
|
|
||||||
|
|
||||||
// Fetch contacts from database using mixin's cached method
|
|
||||||
const allContacts = await this.$contacts();
|
|
||||||
|
|
||||||
// Convert contacts to export format
|
|
||||||
const processedContacts: Contact[] = allContacts.map((contact) => {
|
|
||||||
const exContact: Contact = R.omit(["contactMethods"], contact);
|
|
||||||
exContact.contactMethods = contact.contactMethods
|
|
||||||
? typeof contact.contactMethods === "string" &&
|
|
||||||
contact.contactMethods.trim() !== ""
|
|
||||||
? JSON.parse(contact.contactMethods)
|
|
||||||
: []
|
|
||||||
: [];
|
|
||||||
return exContact;
|
|
||||||
});
|
|
||||||
|
|
||||||
const exportData = contactsToExportJson(processedContacts);
|
|
||||||
const jsonStr = JSON.stringify(exportData, null, 2);
|
|
||||||
|
|
||||||
// Use platform service to share the file
|
|
||||||
await this.platformService.writeAndShareFile(this.fileName, jsonStr);
|
|
||||||
|
|
||||||
this.notify.success(
|
|
||||||
"Contact sharing completed successfully. Use the share dialog to send to your preferred app.",
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Share Error:", error);
|
|
||||||
this.notify.error(
|
|
||||||
`There was an error sharing the data: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
this.isExporting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves contacts data directly to the device's storage
|
|
||||||
* Uses platform-specific save methods (Downloads folder, Documents, etc.)
|
|
||||||
*
|
|
||||||
* @throws {Error} If saving fails
|
|
||||||
*/
|
|
||||||
public async saveContactsToDevice(): Promise<void> {
|
|
||||||
if (this.isExporting) {
|
|
||||||
return; // Prevent multiple simultaneous exports
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.isExporting = true;
|
|
||||||
|
|
||||||
// Fetch contacts from database using mixin's cached method
|
|
||||||
const allContacts = await this.$contacts();
|
|
||||||
|
|
||||||
// Convert contacts to export format
|
|
||||||
const processedContacts: Contact[] = allContacts.map((contact) => {
|
|
||||||
const exContact: Contact = R.omit(["contactMethods"], contact);
|
|
||||||
exContact.contactMethods = contact.contactMethods
|
|
||||||
? typeof contact.contactMethods === "string" &&
|
|
||||||
contact.contactMethods.trim() !== ""
|
|
||||||
? JSON.parse(contact.contactMethods)
|
|
||||||
: []
|
|
||||||
: [];
|
|
||||||
return exContact;
|
|
||||||
});
|
|
||||||
|
|
||||||
const exportData = contactsToExportJson(processedContacts);
|
|
||||||
const jsonStr = JSON.stringify(exportData, null, 2);
|
|
||||||
|
|
||||||
// Use platform service to save directly to device
|
|
||||||
await this.platformService.saveToDevice(this.fileName, jsonStr);
|
|
||||||
|
|
||||||
this.notify.success("Contact data saved successfully to your device.");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Save Error:", error);
|
|
||||||
this.notify.error(
|
|
||||||
`There was an error saving the data: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
this.isExporting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
created() {
|
created() {
|
||||||
this.notify = createNotifyHelpers(this.$notify);
|
this.notify = createNotifyHelpers(this.$notify);
|
||||||
this.loadSeedBackupStatus();
|
this.loadSeedBackupStatus();
|
||||||
|
|||||||
@@ -234,32 +234,20 @@ export async function runMigrations<T>(
|
|||||||
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
|
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
|
||||||
extractMigrationNames: (result: T) => Set<string>,
|
extractMigrationNames: (result: T) => Set<string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Only log migration start in development
|
logger.debug("[Migration] Starting database migrations");
|
||||||
const isDevelopment = process.env.VITE_PLATFORM === "development";
|
|
||||||
if (isDevelopment) {
|
|
||||||
logger.debug("[Migration] Starting database migrations");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const migration of MIGRATIONS) {
|
for (const migration of MIGRATIONS) {
|
||||||
if (isDevelopment) {
|
logger.debug("[Migration] Registering migration:", migration.name);
|
||||||
logger.debug("[Migration] Registering migration:", migration.name);
|
|
||||||
}
|
|
||||||
registerMigration(migration);
|
registerMigration(migration);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDevelopment) {
|
logger.debug("[Migration] Running migration service");
|
||||||
logger.debug("[Migration] Running migration service");
|
|
||||||
}
|
|
||||||
await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames);
|
await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames);
|
||||||
|
|
||||||
if (isDevelopment) {
|
logger.debug("[Migration] Database migrations completed");
|
||||||
logger.debug("[Migration] Database migrations completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bootstrapping: Ensure active account is selected after migrations
|
// Bootstrapping: Ensure active account is selected after migrations
|
||||||
if (isDevelopment) {
|
logger.debug("[Migration] Running bootstrapping hooks");
|
||||||
logger.debug("[Migration] Running bootstrapping hooks");
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
// Check if we have accounts but no active selection
|
// Check if we have accounts but no active selection
|
||||||
const accountsResult = await sqlQuery("SELECT COUNT(*) FROM accounts");
|
const accountsResult = await sqlQuery("SELECT COUNT(*) FROM accounts");
|
||||||
@@ -274,18 +262,14 @@ export async function runMigrations<T>(
|
|||||||
activeDid = (extractSingleValue(activeResult) as string) || null;
|
activeDid = (extractSingleValue(activeResult) as string) || null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Table doesn't exist - migration 004 may not have run yet
|
// Table doesn't exist - migration 004 may not have run yet
|
||||||
if (isDevelopment) {
|
logger.debug(
|
||||||
logger.debug(
|
"[Migration] active_identity table not found - migration may not have run",
|
||||||
"[Migration] active_identity table not found - migration may not have run",
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
activeDid = null;
|
activeDid = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountsCount > 0 && (!activeDid || activeDid === "")) {
|
if (accountsCount > 0 && (!activeDid || activeDid === "")) {
|
||||||
if (isDevelopment) {
|
logger.debug("[Migration] Auto-selecting first account as active");
|
||||||
logger.debug("[Migration] Auto-selecting first account as active");
|
|
||||||
}
|
|
||||||
const firstAccountResult = await sqlQuery(
|
const firstAccountResult = await sqlQuery(
|
||||||
"SELECT did FROM accounts ORDER BY dateCreated, did LIMIT 1",
|
"SELECT did FROM accounts ORDER BY dateCreated, did LIMIT 1",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ export interface AgreeActionClaim extends ClaimObject {
|
|||||||
object: Record<string, unknown>;
|
object: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmojiClaim extends ClaimObject {
|
||||||
|
// default context is "https://endorser.ch"
|
||||||
|
"@type": "Emoji";
|
||||||
|
text: string;
|
||||||
|
parentItem: { lastClaimId: string };
|
||||||
|
}
|
||||||
|
|
||||||
// Note that previous VCs may have additional fields.
|
// Note that previous VCs may have additional fields.
|
||||||
// https://endorser.ch/doc/html/transactions.html#id4
|
// https://endorser.ch/doc/html/transactions.html#id4
|
||||||
export interface GiveActionClaim extends ClaimObject {
|
export interface GiveActionClaim extends ClaimObject {
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ export interface UserInfo {
|
|||||||
|
|
||||||
export interface CreateAndSubmitClaimResult {
|
export interface CreateAndSubmitClaimResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
embeddedRecordError?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
claimId?: string;
|
||||||
handleId?: string;
|
handleId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,6 @@
|
|||||||
export type {
|
export * from "./claims";
|
||||||
// From common.ts
|
|
||||||
CreateAndSubmitClaimResult,
|
|
||||||
GenericCredWrapper,
|
|
||||||
GenericVerifiableCredential,
|
|
||||||
KeyMeta,
|
|
||||||
// Exclude types that are also exported from other files
|
|
||||||
// GiveVerifiableCredential,
|
|
||||||
// OfferVerifiableCredential,
|
|
||||||
// RegisterVerifiableCredential,
|
|
||||||
// PlanSummaryRecord,
|
|
||||||
// UserInfo,
|
|
||||||
} from "./common";
|
|
||||||
|
|
||||||
export type {
|
|
||||||
// From claims.ts
|
|
||||||
GiveActionClaim,
|
|
||||||
OfferClaim,
|
|
||||||
RegisterActionClaim,
|
|
||||||
} from "./claims";
|
|
||||||
|
|
||||||
export type {
|
|
||||||
// From records.ts
|
|
||||||
PlanSummaryRecord,
|
|
||||||
} from "./records";
|
|
||||||
|
|
||||||
export type {
|
|
||||||
// From user.ts
|
|
||||||
UserInfo,
|
|
||||||
} from "./user";
|
|
||||||
|
|
||||||
export * from "./limits";
|
|
||||||
export * from "./deepLinks";
|
|
||||||
export * from "./common";
|
|
||||||
export * from "./claims-result";
|
export * from "./claims-result";
|
||||||
|
export * from "./common";
|
||||||
|
export * from "./deepLinks";
|
||||||
|
export * from "./limits";
|
||||||
export * from "./records";
|
export * from "./records";
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
import { GiveActionClaim, OfferClaim, PlanActionClaim } from "./claims";
|
import { GiveActionClaim, OfferClaim, PlanActionClaim } from "./claims";
|
||||||
import { GenericCredWrapper } from "./common";
|
import { GenericCredWrapper } from "./common";
|
||||||
|
|
||||||
|
export interface EmojiSummaryRecord {
|
||||||
|
issuerDid: string;
|
||||||
|
jwtId: string;
|
||||||
|
text: string;
|
||||||
|
parentHandleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
// a summary record; the VC is found the fullClaim field
|
// a summary record; the VC is found the fullClaim field
|
||||||
export interface GiveSummaryRecord {
|
export interface GiveSummaryRecord {
|
||||||
[x: string]: PropertyKey | undefined | GiveActionClaim;
|
[x: string]:
|
||||||
|
| PropertyKey
|
||||||
|
| undefined
|
||||||
|
| GiveActionClaim
|
||||||
|
| Record<string, number>;
|
||||||
type?: string;
|
type?: string;
|
||||||
agentDid: string;
|
agentDid: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
amountConfirmed: number;
|
amountConfirmed: number;
|
||||||
description: string;
|
description: string;
|
||||||
|
emojiCount: Record<string, number>; // Map of emoji character to count
|
||||||
fullClaim: GiveActionClaim;
|
fullClaim: GiveActionClaim;
|
||||||
fulfillsHandleId: string;
|
fulfillsHandleId: string;
|
||||||
fulfillsPlanHandleId?: string;
|
fulfillsPlanHandleId?: string;
|
||||||
|
|||||||
@@ -630,11 +630,7 @@ async function performPlanRequest(
|
|||||||
|
|
||||||
return cred;
|
return cred;
|
||||||
} else {
|
} else {
|
||||||
// Use debug level for development to reduce console noise
|
logger.debug(
|
||||||
const isDevelopment = process.env.VITE_PLATFORM === "development";
|
|
||||||
const log = isDevelopment ? logger.debug : logger.log;
|
|
||||||
|
|
||||||
log(
|
|
||||||
"[Plan Loading] ⚠️ Plan cache is empty for handle",
|
"[Plan Loading] ⚠️ Plan cache is empty for handle",
|
||||||
handleId,
|
handleId,
|
||||||
" Got data:",
|
" Got data:",
|
||||||
@@ -706,7 +702,7 @@ export function serverMessageForUser(error: unknown): string | undefined {
|
|||||||
export function errorStringForLog(error: unknown) {
|
export function errorStringForLog(error: unknown) {
|
||||||
let stringifiedError = "" + error;
|
let stringifiedError = "" + error;
|
||||||
try {
|
try {
|
||||||
stringifiedError = JSON.stringify(error);
|
stringifiedError = safeStringify(error);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// can happen with Dexie, eg:
|
// can happen with Dexie, eg:
|
||||||
// TypeError: Converting circular structure to JSON
|
// TypeError: Converting circular structure to JSON
|
||||||
@@ -718,7 +714,7 @@ export function errorStringForLog(error: unknown) {
|
|||||||
|
|
||||||
if (error && typeof error === "object" && "response" in error) {
|
if (error && typeof error === "object" && "response" in error) {
|
||||||
const err = error as AxiosErrorResponse;
|
const err = error as AxiosErrorResponse;
|
||||||
const errorResponseText = JSON.stringify(err.response);
|
const errorResponseText = safeStringify(err.response);
|
||||||
// for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions)
|
// for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions)
|
||||||
if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) {
|
if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) {
|
||||||
// add error.response stuff
|
// add error.response stuff
|
||||||
@@ -728,7 +724,7 @@ export function errorStringForLog(error: unknown) {
|
|||||||
R.equals(err.config, err.response.config)
|
R.equals(err.config, err.response.config)
|
||||||
) {
|
) {
|
||||||
// but exclude "config" because it's already in there
|
// but exclude "config" because it's already in there
|
||||||
const newErrorResponseText = JSON.stringify(
|
const newErrorResponseText = safeStringify(
|
||||||
R.omit(["config"] as never[], err.response),
|
R.omit(["config"] as never[], err.response),
|
||||||
);
|
);
|
||||||
fullError +=
|
fullError +=
|
||||||
@@ -1226,7 +1222,12 @@ export async function createAndSubmitClaim(
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, handleId: response.data?.handleId };
|
return {
|
||||||
|
success: true,
|
||||||
|
claimId: response.data?.claimId,
|
||||||
|
handleId: response.data?.handleId,
|
||||||
|
embeddedRecordError: response.data?.embeddedRecordError,
|
||||||
|
};
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Enhanced error logging with comprehensive context
|
// Enhanced error logging with comprehensive context
|
||||||
const requestId = `claim_error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
const requestId = `claim_error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|||||||
115
src/libs/util.ts
115
src/libs/util.ts
@@ -988,11 +988,6 @@ export async function importFromMnemonic(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const mne: string = mnemonic.trim().toLowerCase();
|
const mne: string = mnemonic.trim().toLowerCase();
|
||||||
|
|
||||||
// Check if this is Test User #0
|
|
||||||
const TEST_USER_0_MNEMONIC =
|
|
||||||
"rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage";
|
|
||||||
const isTestUser0 = mne === TEST_USER_0_MNEMONIC;
|
|
||||||
|
|
||||||
// Derive address and keys from mnemonic
|
// Derive address and keys from mnemonic
|
||||||
const [address, privateHex, publicHex] = deriveAddress(mne, derivationPath);
|
const [address, privateHex, publicHex] = deriveAddress(mne, derivationPath);
|
||||||
|
|
||||||
@@ -1007,90 +1002,6 @@ export async function importFromMnemonic(
|
|||||||
|
|
||||||
// Save the new identity
|
// Save the new identity
|
||||||
await saveNewIdentity(newId, mne, derivationPath);
|
await saveNewIdentity(newId, mne, derivationPath);
|
||||||
|
|
||||||
// Set up Test User #0 specific settings
|
|
||||||
if (isTestUser0) {
|
|
||||||
// Set up Test User #0 specific settings with enhanced error handling
|
|
||||||
const platformService = await getPlatformService();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// First, ensure the DID-specific settings record exists
|
|
||||||
await platformService.insertNewDidIntoSettings(newId.did);
|
|
||||||
|
|
||||||
// Then update with Test User #0 specific settings
|
|
||||||
await platformService.updateDidSpecificSettings(newId.did, {
|
|
||||||
firstName: "User Zero",
|
|
||||||
isRegistered: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify the settings were saved correctly
|
|
||||||
const verificationResult = await platformService.dbQuery(
|
|
||||||
"SELECT firstName, isRegistered FROM settings WHERE accountDid = ?",
|
|
||||||
[newId.did],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (verificationResult?.values?.length) {
|
|
||||||
const settings = verificationResult.values[0];
|
|
||||||
const firstName = settings[0];
|
|
||||||
const isRegistered = settings[1];
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"[importFromMnemonic] Test User #0 settings verification",
|
|
||||||
{
|
|
||||||
did: newId.did,
|
|
||||||
firstName,
|
|
||||||
isRegistered,
|
|
||||||
expectedFirstName: "User Zero",
|
|
||||||
expectedIsRegistered: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// If settings weren't saved correctly, try individual updates
|
|
||||||
if (firstName !== "User Zero" || isRegistered !== 1) {
|
|
||||||
logger.warn(
|
|
||||||
"[importFromMnemonic] Test User #0 settings not saved correctly, retrying with individual updates",
|
|
||||||
);
|
|
||||||
|
|
||||||
await platformService.dbExec(
|
|
||||||
"UPDATE settings SET firstName = ? WHERE accountDid = ?",
|
|
||||||
["User Zero", newId.did],
|
|
||||||
);
|
|
||||||
|
|
||||||
await platformService.dbExec(
|
|
||||||
"UPDATE settings SET isRegistered = ? WHERE accountDid = ?",
|
|
||||||
[1, newId.did],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify again
|
|
||||||
const retryResult = await platformService.dbQuery(
|
|
||||||
"SELECT firstName, isRegistered FROM settings WHERE accountDid = ?",
|
|
||||||
[newId.did],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (retryResult?.values?.length) {
|
|
||||||
const retrySettings = retryResult.values[0];
|
|
||||||
logger.debug(
|
|
||||||
"[importFromMnemonic] Test User #0 settings after retry",
|
|
||||||
{
|
|
||||||
firstName: retrySettings[0],
|
|
||||||
isRegistered: retrySettings[1],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.error(
|
|
||||||
"[importFromMnemonic] Failed to verify Test User #0 settings - no record found",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
"[importFromMnemonic] Error setting up Test User #0 settings:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
// Don't throw - allow the import to continue even if settings fail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1147,3 +1058,29 @@ export async function checkForDuplicateAccount(
|
|||||||
|
|
||||||
return (existingAccount?.values?.length ?? 0) > 0;
|
return (existingAccount?.values?.length ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PromiseTracker<T> {
|
||||||
|
private _promise: Promise<T>;
|
||||||
|
private _resolved = false;
|
||||||
|
private _value: T | undefined;
|
||||||
|
|
||||||
|
constructor(promise: Promise<T>) {
|
||||||
|
this._promise = promise.then((value) => {
|
||||||
|
this._resolved = true;
|
||||||
|
this._value = value;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get isResolved(): boolean {
|
||||||
|
return this._resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): T | undefined {
|
||||||
|
return this._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get promise(): Promise<T> {
|
||||||
|
return this._promise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,24 +88,6 @@ export interface PlatformService {
|
|||||||
*/
|
*/
|
||||||
writeAndShareFile(fileName: string, content: string): Promise<void>;
|
writeAndShareFile(fileName: string, content: string): Promise<void>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves content directly to the device's Downloads folder (Android) or Documents folder (iOS).
|
|
||||||
* Uses MediaStore on Android API 29+ and falls back to SAF on older versions.
|
|
||||||
* @param fileName - The filename of the file to save
|
|
||||||
* @param content - The content to write to the file
|
|
||||||
* @returns Promise that resolves when the file is saved
|
|
||||||
*/
|
|
||||||
saveToDevice(fileName: string, content: string): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the system file picker to let the user choose where to save a file.
|
|
||||||
* Uses Storage Access Framework (SAF) on Android and appropriate APIs on other platforms.
|
|
||||||
* @param fileName - The suggested filename for the file
|
|
||||||
* @param content - The content to write to the file
|
|
||||||
* @returns Promise that resolves when the file is saved
|
|
||||||
*/
|
|
||||||
saveAs(fileName: string, content: string): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a file at the specified path.
|
* Deletes a file at the specified path.
|
||||||
* @param path - The path to the file to delete
|
* @param path - The path to the file to delete
|
||||||
|
|||||||
297
src/services/platforms/BaseDatabaseService.ts
Normal file
297
src/services/platforms/BaseDatabaseService.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Base Database Service for Platform Services
|
||||||
|
* @author Matthew Raymer
|
||||||
|
*
|
||||||
|
* This abstract base class provides common database operations that are
|
||||||
|
* identical across all platform implementations. It eliminates code
|
||||||
|
* duplication and ensures consistency in database operations.
|
||||||
|
*
|
||||||
|
* Key Features:
|
||||||
|
* - Common database utility methods
|
||||||
|
* - Consistent settings management
|
||||||
|
* - Active identity management
|
||||||
|
* - Abstract methods for platform-specific database operations
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* - Abstract base class with common implementations
|
||||||
|
* - Platform services extend this class
|
||||||
|
* - Platform-specific database operations remain abstract
|
||||||
|
*
|
||||||
|
* @since 1.1.1-beta
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from "../../utils/logger";
|
||||||
|
import { QueryExecResult } from "@/interfaces/database";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base class for platform-specific database services.
|
||||||
|
*
|
||||||
|
* This class provides common database operations that are identical
|
||||||
|
* across all platform implementations (Web, Capacitor, Electron).
|
||||||
|
* Platform-specific services extend this class and implement the
|
||||||
|
* abstract database operation methods.
|
||||||
|
*
|
||||||
|
* Common Operations:
|
||||||
|
* - Settings management (update, retrieve, insert)
|
||||||
|
* - Active identity management
|
||||||
|
* - Database utility methods
|
||||||
|
*
|
||||||
|
* @abstract
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* export class WebPlatformService extends BaseDatabaseService {
|
||||||
|
* async dbQuery(sql: string, params?: unknown[]): Promise<QueryExecResult> {
|
||||||
|
* // Web-specific implementation
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export abstract class BaseDatabaseService {
|
||||||
|
/**
|
||||||
|
* Generate an INSERT statement for a model object.
|
||||||
|
*
|
||||||
|
* Creates a parameterized INSERT statement with placeholders for
|
||||||
|
* all properties in the model object. This ensures safe SQL
|
||||||
|
* execution and prevents SQL injection.
|
||||||
|
*
|
||||||
|
* @param model - Object containing the data to insert
|
||||||
|
* @param tableName - Name of the target table
|
||||||
|
* @returns Object containing the SQL statement and parameters
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const { sql, params } = this.generateInsertStatement(
|
||||||
|
* { name: 'John', age: 30 },
|
||||||
|
* 'users'
|
||||||
|
* );
|
||||||
|
* // sql: "INSERT INTO users (name, age) VALUES (?, ?)"
|
||||||
|
* // params: ['John', 30]
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
generateInsertStatement(
|
||||||
|
model: Record<string, unknown>,
|
||||||
|
tableName: string,
|
||||||
|
): { sql: string; params: unknown[] } {
|
||||||
|
const keys = Object.keys(model);
|
||||||
|
const placeholders = keys.map(() => "?").join(", ");
|
||||||
|
const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`;
|
||||||
|
const params = keys.map((key) => model[key]);
|
||||||
|
return { sql, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update default settings for the currently active account.
|
||||||
|
*
|
||||||
|
* Retrieves the active DID from the active_identity table and updates
|
||||||
|
* the corresponding settings record. This ensures settings are always
|
||||||
|
* updated for the correct account.
|
||||||
|
*
|
||||||
|
* @param settings - Object containing the settings to update
|
||||||
|
* @returns Promise that resolves when settings are updated
|
||||||
|
*
|
||||||
|
* @throws {Error} If no active DID is found or database operation fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* await this.updateDefaultSettings({
|
||||||
|
* theme: 'dark',
|
||||||
|
* notifications: true
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
async updateDefaultSettings(
|
||||||
|
settings: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
// Get current active DID and update that identity's settings
|
||||||
|
const activeIdentity = await this.getActiveIdentity();
|
||||||
|
const activeDid = activeIdentity.activeDid;
|
||||||
|
|
||||||
|
if (!activeDid) {
|
||||||
|
logger.warn(
|
||||||
|
"[BaseDatabaseService] No active DID found, cannot update default settings",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(settings);
|
||||||
|
const setClause = keys.map((key) => `${key} = ?`).join(", ");
|
||||||
|
const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`;
|
||||||
|
const params = [...keys.map((key) => settings[key]), activeDid];
|
||||||
|
await this.dbExec(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the active DID in the active_identity table.
|
||||||
|
*
|
||||||
|
* Sets the active DID and updates the lastUpdated timestamp.
|
||||||
|
* This is used when switching between different accounts/identities.
|
||||||
|
*
|
||||||
|
* @param did - The DID to set as active
|
||||||
|
* @returns Promise that resolves when the update is complete
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* await this.updateActiveDid('did:example:123');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
async updateActiveDid(did: string): Promise<void> {
|
||||||
|
await this.dbExec(
|
||||||
|
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
|
||||||
|
[did],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently active DID from the active_identity table.
|
||||||
|
*
|
||||||
|
* Retrieves the active DID that represents the currently selected
|
||||||
|
* account/identity. This is used throughout the application to
|
||||||
|
* ensure operations are performed on the correct account.
|
||||||
|
*
|
||||||
|
* @returns Promise resolving to object containing the active DID
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const { activeDid } = await this.getActiveIdentity();
|
||||||
|
* console.log('Current active DID:', activeDid);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
async getActiveIdentity(): Promise<{ activeDid: string }> {
|
||||||
|
const result = (await this.dbQuery(
|
||||||
|
"SELECT activeDid FROM active_identity WHERE id = 1",
|
||||||
|
)) as QueryExecResult;
|
||||||
|
return {
|
||||||
|
activeDid: (result?.values?.[0]?.[0] as string) || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a new DID into the settings table with default values.
|
||||||
|
*
|
||||||
|
* Creates a new settings record for a DID with default configuration
|
||||||
|
* values. Uses INSERT OR REPLACE to handle cases where settings
|
||||||
|
* already exist for the DID.
|
||||||
|
*
|
||||||
|
* @param did - The DID to create settings for
|
||||||
|
* @returns Promise that resolves when settings are created
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* await this.insertNewDidIntoSettings('did:example:123');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
async insertNewDidIntoSettings(did: string): Promise<void> {
|
||||||
|
// Import constants dynamically to avoid circular dependencies
|
||||||
|
const { DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER } =
|
||||||
|
await import("@/constants/app");
|
||||||
|
|
||||||
|
// Use INSERT OR REPLACE to handle case where settings already exist for this DID
|
||||||
|
// This prevents duplicate accountDid entries and ensures data integrity
|
||||||
|
await this.dbExec(
|
||||||
|
"INSERT OR REPLACE INTO settings (accountDid, finishedOnboarding, apiServer, partnerApiServer) VALUES (?, ?, ?, ?)",
|
||||||
|
[did, false, DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update settings for a specific DID.
|
||||||
|
*
|
||||||
|
* Updates settings for a particular DID rather than the active one.
|
||||||
|
* This is useful for bulk operations or when managing multiple accounts.
|
||||||
|
*
|
||||||
|
* @param did - The DID to update settings for
|
||||||
|
* @param settings - Object containing the settings to update
|
||||||
|
* @returns Promise that resolves when settings are updated
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* await this.updateDidSpecificSettings('did:example:123', {
|
||||||
|
* theme: 'light',
|
||||||
|
* notifications: false
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
async updateDidSpecificSettings(
|
||||||
|
did: string,
|
||||||
|
settings: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const keys = Object.keys(settings);
|
||||||
|
const setClause = keys.map((key) => `${key} = ?`).join(", ");
|
||||||
|
const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`;
|
||||||
|
const params = [...keys.map((key) => settings[key]), did];
|
||||||
|
await this.dbExec(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve settings for the currently active account.
|
||||||
|
*
|
||||||
|
* Gets the active DID and retrieves all settings for that account.
|
||||||
|
* Excludes the 'id' column from the returned settings object.
|
||||||
|
*
|
||||||
|
* @returns Promise resolving to settings object or null if no active DID
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const settings = await this.retrieveSettingsForActiveAccount();
|
||||||
|
* if (settings) {
|
||||||
|
* console.log('Theme:', settings.theme);
|
||||||
|
* console.log('Notifications:', settings.notifications);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
async retrieveSettingsForActiveAccount(): Promise<Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null> {
|
||||||
|
// Get current active DID from active_identity table
|
||||||
|
const activeIdentity = await this.getActiveIdentity();
|
||||||
|
const activeDid = activeIdentity.activeDid;
|
||||||
|
|
||||||
|
if (!activeDid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await this.dbQuery(
|
||||||
|
"SELECT * FROM settings WHERE accountDid = ?",
|
||||||
|
[activeDid],
|
||||||
|
)) as QueryExecResult;
|
||||||
|
if (result?.values?.[0]) {
|
||||||
|
// Convert the row to an object
|
||||||
|
const row = result.values[0];
|
||||||
|
const columns = result.columns || [];
|
||||||
|
const settings: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
columns.forEach((column: string, index: number) => {
|
||||||
|
if (column !== "id") {
|
||||||
|
// Exclude the id column
|
||||||
|
settings[column] = row[index];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abstract methods that must be implemented by platform-specific services
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a database query (SELECT operations).
|
||||||
|
*
|
||||||
|
* @abstract
|
||||||
|
* @param sql - SQL query string
|
||||||
|
* @param params - Optional parameters for prepared statements
|
||||||
|
* @returns Promise resolving to query results
|
||||||
|
*/
|
||||||
|
abstract dbQuery(sql: string, params?: unknown[]): Promise<unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a database statement (INSERT, UPDATE, DELETE operations).
|
||||||
|
*
|
||||||
|
* @abstract
|
||||||
|
* @param sql - SQL statement string
|
||||||
|
* @param params - Optional parameters for prepared statements
|
||||||
|
* @returns Promise resolving to execution results
|
||||||
|
*/
|
||||||
|
abstract dbExec(sql: string, params?: unknown[]): Promise<unknown>;
|
||||||
|
}
|
||||||
@@ -14,65 +14,6 @@ import {
|
|||||||
DBSQLiteValues,
|
DBSQLiteValues,
|
||||||
} from "@capacitor-community/sqlite";
|
} from "@capacitor-community/sqlite";
|
||||||
|
|
||||||
// Android-specific imports for SAF and MediaStore
|
|
||||||
import { registerPlugin } from "@capacitor/core";
|
|
||||||
|
|
||||||
// Define interfaces for Android-specific functionality
|
|
||||||
interface AndroidFileSaverPlugin {
|
|
||||||
saveToDownloads(options: {
|
|
||||||
fileName: string;
|
|
||||||
content: string;
|
|
||||||
mimeType: string;
|
|
||||||
}): Promise<{ success: boolean; path?: string; error?: string }>;
|
|
||||||
saveAs(options: {
|
|
||||||
fileName: string;
|
|
||||||
content: string;
|
|
||||||
mimeType: string;
|
|
||||||
}): Promise<{ success: boolean; path?: string; error?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register the plugin (will be undefined if not available)
|
|
||||||
const AndroidFileSaver =
|
|
||||||
registerPlugin<AndroidFileSaverPlugin>("AndroidFileSaver");
|
|
||||||
|
|
||||||
// Fallback implementation for when the plugin is not available
|
|
||||||
class AndroidFileSaverFallback implements AndroidFileSaverPlugin {
|
|
||||||
async saveToDownloads(options: {
|
|
||||||
fileName: string;
|
|
||||||
content: string;
|
|
||||||
mimeType: string;
|
|
||||||
}): Promise<{ success: boolean; path?: string; error?: string }> {
|
|
||||||
logger.warn(
|
|
||||||
"[CapacitorPlatformService] AndroidFileSaver plugin not available, using fallback",
|
|
||||||
{
|
|
||||||
fileName: options.fileName,
|
|
||||||
mimeType: options.mimeType,
|
|
||||||
contentLength: options.content.length,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return { success: false, error: "AndroidFileSaver plugin not available" };
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveAs(options: {
|
|
||||||
fileName: string;
|
|
||||||
content: string;
|
|
||||||
mimeType: string;
|
|
||||||
}): Promise<{ success: boolean; path?: string; error?: string }> {
|
|
||||||
logger.warn(
|
|
||||||
"[CapacitorPlatformService] AndroidFileSaver plugin not available, using fallback",
|
|
||||||
{
|
|
||||||
fileName: options.fileName,
|
|
||||||
mimeType: options.mimeType,
|
|
||||||
contentLength: options.content.length,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return { success: false, error: "AndroidFileSaver plugin not available" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use fallback if plugin is not available
|
|
||||||
const AndroidFileSaverImpl = AndroidFileSaver || new AndroidFileSaverFallback();
|
|
||||||
|
|
||||||
import { runMigrations } from "@/db-sql/migration";
|
import { runMigrations } from "@/db-sql/migration";
|
||||||
import { QueryExecResult } from "@/interfaces/database";
|
import { QueryExecResult } from "@/interfaces/database";
|
||||||
import {
|
import {
|
||||||
@@ -81,6 +22,7 @@ import {
|
|||||||
PlatformCapabilities,
|
PlatformCapabilities,
|
||||||
} from "../PlatformService";
|
} from "../PlatformService";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
|
import { BaseDatabaseService } from "./BaseDatabaseService";
|
||||||
|
|
||||||
interface QueuedOperation {
|
interface QueuedOperation {
|
||||||
type: "run" | "query" | "rawQuery";
|
type: "run" | "query" | "rawQuery";
|
||||||
@@ -98,7 +40,10 @@ interface QueuedOperation {
|
|||||||
* - Platform-specific features
|
* - Platform-specific features
|
||||||
* - SQLite database operations
|
* - SQLite database operations
|
||||||
*/
|
*/
|
||||||
export class CapacitorPlatformService implements PlatformService {
|
export class CapacitorPlatformService
|
||||||
|
extends BaseDatabaseService
|
||||||
|
implements PlatformService
|
||||||
|
{
|
||||||
/** Current camera direction */
|
/** Current camera direction */
|
||||||
private currentDirection: CameraDirection = CameraDirection.Rear;
|
private currentDirection: CameraDirection = CameraDirection.Rear;
|
||||||
|
|
||||||
@@ -111,6 +56,7 @@ export class CapacitorPlatformService implements PlatformService {
|
|||||||
private isProcessingQueue: boolean = false;
|
private isProcessingQueue: boolean = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
super();
|
||||||
this.sqlite = new SQLiteConnection(CapacitorSQLite);
|
this.sqlite = new SQLiteConnection(CapacitorSQLite);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,7 +473,7 @@ export class CapacitorPlatformService implements PlatformService {
|
|||||||
* ## Logging:
|
* ## Logging:
|
||||||
*
|
*
|
||||||
* Detailed logging is provided throughout the process using emoji-tagged
|
* Detailed logging is provided throughout the process using emoji-tagged
|
||||||
* log messages that appear in the Electron DevTools. This
|
* console messages that appear in the Electron DevTools console. This
|
||||||
* includes:
|
* includes:
|
||||||
* - SQL statement execution details
|
* - SQL statement execution details
|
||||||
* - Parameter values for debugging
|
* - Parameter values for debugging
|
||||||
@@ -1178,280 +1124,6 @@ export class CapacitorPlatformService implements PlatformService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves content directly to the device's Downloads folder (Android) or Documents folder (iOS).
|
|
||||||
* Uses MediaStore on Android API 29+ and falls back to SAF on older versions.
|
|
||||||
*
|
|
||||||
* @param fileName - The filename of the file to save
|
|
||||||
* @param content - The content to write to the file
|
|
||||||
* @returns Promise that resolves when the file is saved
|
|
||||||
*/
|
|
||||||
async saveToDevice(fileName: string, content: string): Promise<void> {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const logData = {
|
|
||||||
action: "saveToDevice",
|
|
||||||
fileName,
|
|
||||||
contentLength: content.length,
|
|
||||||
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
|
|
||||||
timestamp,
|
|
||||||
};
|
|
||||||
logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Validate JSON content
|
|
||||||
try {
|
|
||||||
JSON.parse(content);
|
|
||||||
} catch {
|
|
||||||
throw new Error('Content must be valid JSON');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique filename
|
|
||||||
const uniqueFileName = this.generateUniqueFileName(fileName);
|
|
||||||
|
|
||||||
if (this.getCapabilities().isIOS) {
|
|
||||||
// iOS: Use Filesystem to save to Documents directory
|
|
||||||
const { uri } = await Filesystem.writeFile({
|
|
||||||
path: uniqueFileName,
|
|
||||||
data: content,
|
|
||||||
directory: Directory.Documents,
|
|
||||||
encoding: Encoding.UTF8,
|
|
||||||
recursive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.log("[CapacitorPlatformService] File saved to iOS Documents:", {
|
|
||||||
uri,
|
|
||||||
fileName: uniqueFileName,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Android: Try to use native MediaStore/SAF implementation
|
|
||||||
const result = await AndroidFileSaverImpl.saveToDownloads({
|
|
||||||
fileName: uniqueFileName,
|
|
||||||
content,
|
|
||||||
mimeType: this.getMimeType(uniqueFileName),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
logger.log(
|
|
||||||
"[CapacitorPlatformService] File saved to Android Downloads:",
|
|
||||||
{
|
|
||||||
path: result.path,
|
|
||||||
fileName: uniqueFileName,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Failed to save to Downloads: ${result.error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error;
|
|
||||||
const errLog = {
|
|
||||||
message: err.message,
|
|
||||||
stack: err.stack,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
logger.error(
|
|
||||||
"[CapacitorPlatformService] Error saving file to device:",
|
|
||||||
JSON.stringify(errLog, null, 2),
|
|
||||||
);
|
|
||||||
throw new Error(`Failed to save file to device: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the system file picker to let the user choose where to save a file.
|
|
||||||
* Uses Storage Access Framework (SAF) on Android and appropriate APIs on other platforms.
|
|
||||||
*
|
|
||||||
* @param fileName - The suggested filename for the file
|
|
||||||
* @param content - The content to write to the file
|
|
||||||
* @returns Promise that resolves when the file is saved
|
|
||||||
*/
|
|
||||||
async saveAs(fileName: string, content: string): Promise<void> {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const logData = {
|
|
||||||
action: "saveAs",
|
|
||||||
fileName,
|
|
||||||
contentLength: content.length,
|
|
||||||
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
|
|
||||||
timestamp,
|
|
||||||
};
|
|
||||||
logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Validate JSON content
|
|
||||||
try {
|
|
||||||
JSON.parse(content);
|
|
||||||
} catch {
|
|
||||||
throw new Error('Content must be valid JSON');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique filename
|
|
||||||
const uniqueFileName = this.generateUniqueFileName(fileName);
|
|
||||||
|
|
||||||
if (this.getCapabilities().isIOS) {
|
|
||||||
// iOS: Use Filesystem to save to Documents directory with user choice
|
|
||||||
const { uri } = await Filesystem.writeFile({
|
|
||||||
path: uniqueFileName,
|
|
||||||
data: content,
|
|
||||||
directory: Directory.Documents,
|
|
||||||
encoding: Encoding.UTF8,
|
|
||||||
recursive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.log("[CapacitorPlatformService] File saved to iOS Documents:", {
|
|
||||||
uri,
|
|
||||||
fileName: uniqueFileName,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Android: Use SAF for user-chosen location
|
|
||||||
const result = await AndroidFileSaverImpl.saveAs({
|
|
||||||
fileName: uniqueFileName,
|
|
||||||
content,
|
|
||||||
mimeType: this.getMimeType(uniqueFileName),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
logger.log(
|
|
||||||
"[CapacitorPlatformService] File saved via Android SAF:",
|
|
||||||
{
|
|
||||||
path: result.path,
|
|
||||||
fileName: uniqueFileName,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Failed to save via SAF: ${result.error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error;
|
|
||||||
const errLog = {
|
|
||||||
message: err.message,
|
|
||||||
stack: err.stack,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
logger.error(
|
|
||||||
"[CapacitorPlatformService] Error in saveAs:",
|
|
||||||
JSON.stringify(errLog, null, 2),
|
|
||||||
);
|
|
||||||
throw new Error(`Failed to save file: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates unique filename with timestamp, hashed device ID, and counter
|
|
||||||
*/
|
|
||||||
private generateUniqueFileName(baseName: string, counter = 0): string {
|
|
||||||
const now = new Date();
|
|
||||||
const timestamp = now.toISOString()
|
|
||||||
.replace(/[:.]/g, '-')
|
|
||||||
.replace('T', '_')
|
|
||||||
.replace('Z', '');
|
|
||||||
|
|
||||||
const deviceIdHash = this.getHashedDeviceIdentifier();
|
|
||||||
const counterSuffix = counter > 0 ? `_${counter}` : '';
|
|
||||||
|
|
||||||
const maxBaseLength = 45;
|
|
||||||
const truncatedBase = baseName.length > maxBaseLength
|
|
||||||
? baseName.substring(0, maxBaseLength)
|
|
||||||
: baseName;
|
|
||||||
|
|
||||||
const nameWithoutExt = truncatedBase.replace(/\.json$/i, '');
|
|
||||||
const extension = '.json';
|
|
||||||
const devicePart = `_${deviceIdHash}`;
|
|
||||||
const timestampPart = `_${timestamp}${counterSuffix}`;
|
|
||||||
|
|
||||||
const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length;
|
|
||||||
|
|
||||||
if (totalLength > 200) {
|
|
||||||
const availableLength = 200 - devicePart.length - timestampPart.length - extension.length;
|
|
||||||
const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength));
|
|
||||||
return `${finalBase}${devicePart}${timestampPart}${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets hashed device identifier
|
|
||||||
*/
|
|
||||||
private getHashedDeviceIdentifier(): string {
|
|
||||||
try {
|
|
||||||
const deviceInfo = this.getDeviceInfo();
|
|
||||||
return this.hashString(deviceInfo);
|
|
||||||
} catch (error) {
|
|
||||||
return 'mobile';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets device info string
|
|
||||||
*/
|
|
||||||
private getDeviceInfo(): string {
|
|
||||||
try {
|
|
||||||
// For mobile platforms, use device info
|
|
||||||
const capabilities = this.getCapabilities();
|
|
||||||
if (capabilities.isIOS) {
|
|
||||||
return 'ios_mobile';
|
|
||||||
} else if (capabilities.isAndroid) {
|
|
||||||
return 'android_mobile';
|
|
||||||
} else {
|
|
||||||
return 'mobile';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return 'mobile';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple hash function for device ID
|
|
||||||
*/
|
|
||||||
private hashString(str: string): string {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
const char = str.charCodeAt(i);
|
|
||||||
hash = ((hash << 5) - hash) + char;
|
|
||||||
hash = hash & hash;
|
|
||||||
}
|
|
||||||
return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines the MIME type for a given filename based on its extension.
|
|
||||||
*
|
|
||||||
* @param fileName - The filename to determine MIME type for
|
|
||||||
* @returns The MIME type string
|
|
||||||
*/
|
|
||||||
private getMimeType(fileName: string): string {
|
|
||||||
const extension = fileName.split(".").pop()?.toLowerCase();
|
|
||||||
|
|
||||||
switch (extension) {
|
|
||||||
case "json":
|
|
||||||
return "application/json";
|
|
||||||
case "txt":
|
|
||||||
return "text/plain";
|
|
||||||
case "csv":
|
|
||||||
return "text/csv";
|
|
||||||
case "pdf":
|
|
||||||
return "application/pdf";
|
|
||||||
case "xml":
|
|
||||||
return "application/xml";
|
|
||||||
case "html":
|
|
||||||
return "text/html";
|
|
||||||
case "jpg":
|
|
||||||
case "jpeg":
|
|
||||||
return "image/jpeg";
|
|
||||||
case "png":
|
|
||||||
return "image/png";
|
|
||||||
case "gif":
|
|
||||||
return "image/gif";
|
|
||||||
default:
|
|
||||||
return "application/octet-stream";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a file from the app's data directory.
|
* Deletes a file from the app's data directory.
|
||||||
* @param path - Relative path to the file to delete
|
* @param path - Relative path to the file to delete
|
||||||
@@ -1661,79 +1333,8 @@ export class CapacitorPlatformService implements PlatformService {
|
|||||||
// --- PWA/Web-only methods (no-op for Capacitor) ---
|
// --- PWA/Web-only methods (no-op for Capacitor) ---
|
||||||
public registerServiceWorker(): void {}
|
public registerServiceWorker(): void {}
|
||||||
|
|
||||||
// Database utility methods
|
// Database utility methods - inherited from BaseDatabaseService
|
||||||
generateInsertStatement(
|
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
|
||||||
model: Record<string, unknown>,
|
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
|
||||||
tableName: string,
|
// retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService
|
||||||
): { sql: string; params: unknown[] } {
|
|
||||||
const keys = Object.keys(model);
|
|
||||||
const placeholders = keys.map(() => "?").join(", ");
|
|
||||||
const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`;
|
|
||||||
const params = keys.map((key) => model[key]);
|
|
||||||
return { sql, params };
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateDefaultSettings(
|
|
||||||
settings: Record<string, unknown>,
|
|
||||||
): Promise<void> {
|
|
||||||
const keys = Object.keys(settings);
|
|
||||||
const setClause = keys.map((key) => `${key} = ?`).join(", ");
|
|
||||||
const sql = `UPDATE settings SET ${setClause} WHERE id = 1`;
|
|
||||||
const params = keys.map((key) => settings[key]);
|
|
||||||
await this.dbExec(sql, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateActiveDid(did: string): Promise<void> {
|
|
||||||
await this.dbExec(
|
|
||||||
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
|
|
||||||
[did],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async insertNewDidIntoSettings(did: string): Promise<void> {
|
|
||||||
// Import constants dynamically to avoid circular dependencies
|
|
||||||
const { DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER } =
|
|
||||||
await import("@/constants/app");
|
|
||||||
|
|
||||||
// Use INSERT OR REPLACE to handle case where settings already exist for this DID
|
|
||||||
// This prevents duplicate accountDid entries and ensures data integrity
|
|
||||||
await this.dbExec(
|
|
||||||
"INSERT OR REPLACE INTO settings (accountDid, finishedOnboarding, apiServer, partnerApiServer) VALUES (?, ?, ?, ?)",
|
|
||||||
[did, false, DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateDidSpecificSettings(
|
|
||||||
did: string,
|
|
||||||
settings: Record<string, unknown>,
|
|
||||||
): Promise<void> {
|
|
||||||
const keys = Object.keys(settings);
|
|
||||||
const setClause = keys.map((key) => `${key} = ?`).join(", ");
|
|
||||||
const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`;
|
|
||||||
const params = [...keys.map((key) => settings[key]), did];
|
|
||||||
await this.dbExec(sql, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
async retrieveSettingsForActiveAccount(): Promise<Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
> | null> {
|
|
||||||
const result = await this.dbQuery("SELECT * FROM settings WHERE id = 1");
|
|
||||||
if (result?.values?.[0]) {
|
|
||||||
// Convert the row to an object
|
|
||||||
const row = result.values[0];
|
|
||||||
const columns = result.columns || [];
|
|
||||||
const settings: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
columns.forEach((column, index) => {
|
|
||||||
if (column !== "id") {
|
|
||||||
// Exclude the id column
|
|
||||||
settings[column] = row[index];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,236 +146,6 @@ export class ElectronPlatformService extends CapacitorPlatformService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves content directly to the device's Downloads folder (Electron platform).
|
|
||||||
* Uses Electron's IPC to save files directly to the Downloads directory.
|
|
||||||
*
|
|
||||||
* @param fileName - The filename of the file to save
|
|
||||||
* @param content - The content to write to the file
|
|
||||||
* @returns Promise that resolves when the file is saved
|
|
||||||
*/
|
|
||||||
async saveToDevice(fileName: string, content: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Ensure content is valid JSON
|
|
||||||
try {
|
|
||||||
JSON.parse(content);
|
|
||||||
} catch {
|
|
||||||
throw new Error('Content must be valid JSON');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique filename
|
|
||||||
const uniqueFileName = this.generateUniqueFileNameElectron(fileName);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`[ElectronPlatformService] Using native IPC for direct file save`,
|
|
||||||
uniqueFileName,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if we're running in Electron with the API available
|
|
||||||
if (typeof window !== "undefined" && window.electronAPI) {
|
|
||||||
// Use the native Electron IPC API for file exports
|
|
||||||
const result = await window.electronAPI.exportData(uniqueFileName, content);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
logger.info(
|
|
||||||
`[ElectronPlatformService] File saved successfully to`,
|
|
||||||
result.path,
|
|
||||||
);
|
|
||||||
logger.info(
|
|
||||||
`[ElectronPlatformService] File saved to Downloads folder`,
|
|
||||||
uniqueFileName,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.error(
|
|
||||||
`[ElectronPlatformService] Native save failed`,
|
|
||||||
result.error,
|
|
||||||
);
|
|
||||||
throw new Error(`Native file save failed: ${result.error}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to web-style download if Electron API is not available
|
|
||||||
logger.warn(
|
|
||||||
"[ElectronPlatformService] Electron API not available, falling back to web download",
|
|
||||||
);
|
|
||||||
|
|
||||||
const blob = new Blob([content], { type: "application/json" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const downloadLink = document.createElement("a");
|
|
||||||
downloadLink.href = url;
|
|
||||||
downloadLink.download = uniqueFileName;
|
|
||||||
downloadLink.style.display = "none";
|
|
||||||
|
|
||||||
document.body.appendChild(downloadLink);
|
|
||||||
downloadLink.click();
|
|
||||||
document.body.removeChild(downloadLink);
|
|
||||||
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`[ElectronPlatformService] Fallback download initiated`,
|
|
||||||
uniqueFileName,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("[ElectronPlatformService] File save failed", error);
|
|
||||||
throw new Error(`Failed to save file: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the system file picker to let the user choose where to save a file (Electron platform).
|
|
||||||
* Uses Electron's IPC to show the native save dialog.
|
|
||||||
*
|
|
||||||
* @param fileName - The suggested filename for the file
|
|
||||||
* @param content - The content to write to the file
|
|
||||||
* @returns Promise that resolves when the file is saved
|
|
||||||
*/
|
|
||||||
async saveAs(fileName: string, content: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Ensure content is valid JSON
|
|
||||||
try {
|
|
||||||
JSON.parse(content);
|
|
||||||
} catch {
|
|
||||||
throw new Error('Content must be valid JSON');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique filename
|
|
||||||
const uniqueFileName = this.generateUniqueFileNameElectron(fileName);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`[ElectronPlatformService] Using native IPC for save as dialog`,
|
|
||||||
uniqueFileName,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if we're running in Electron with the API available
|
|
||||||
if (typeof window !== "undefined" && window.electronAPI) {
|
|
||||||
// Use the native Electron IPC API for file exports (same as saveToDevice for now)
|
|
||||||
// TODO: Implement native save dialog when available
|
|
||||||
const result = await window.electronAPI.exportData(uniqueFileName, content);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
logger.info(
|
|
||||||
`[ElectronPlatformService] File saved successfully to`,
|
|
||||||
result.path,
|
|
||||||
);
|
|
||||||
logger.info(
|
|
||||||
`[ElectronPlatformService] File saved via save as`,
|
|
||||||
uniqueFileName,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.error(
|
|
||||||
`[ElectronPlatformService] Native save as failed`,
|
|
||||||
result.error,
|
|
||||||
);
|
|
||||||
throw new Error(`Native file save as failed: ${result.error}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to web-style download if Electron API is not available
|
|
||||||
logger.warn(
|
|
||||||
"[ElectronPlatformService] Electron API not available, falling back to web download",
|
|
||||||
);
|
|
||||||
|
|
||||||
const blob = new Blob([content], { type: "application/json" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const downloadLink = document.createElement("a");
|
|
||||||
downloadLink.href = url;
|
|
||||||
downloadLink.download = uniqueFileName;
|
|
||||||
downloadLink.style.display = "none";
|
|
||||||
|
|
||||||
document.body.appendChild(downloadLink);
|
|
||||||
downloadLink.click();
|
|
||||||
document.body.removeChild(downloadLink);
|
|
||||||
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`[ElectronPlatformService] Fallback download initiated`,
|
|
||||||
uniqueFileName,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("[ElectronPlatformService] File save as failed", error);
|
|
||||||
throw new Error(`Failed to save file as: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates unique filename with timestamp, hashed device ID, and counter
|
|
||||||
*/
|
|
||||||
private generateUniqueFileNameElectron(baseName: string, counter = 0): string {
|
|
||||||
const now = new Date();
|
|
||||||
const timestamp = now.toISOString()
|
|
||||||
.replace(/[:.]/g, '-')
|
|
||||||
.replace('T', '_')
|
|
||||||
.replace('Z', '');
|
|
||||||
|
|
||||||
const deviceIdHash = this.getHashedDeviceIdentifierElectron();
|
|
||||||
const counterSuffix = counter > 0 ? `_${counter}` : '';
|
|
||||||
|
|
||||||
const maxBaseLength = 45;
|
|
||||||
const truncatedBase = baseName.length > maxBaseLength
|
|
||||||
? baseName.substring(0, maxBaseLength)
|
|
||||||
: baseName;
|
|
||||||
|
|
||||||
const nameWithoutExt = truncatedBase.replace(/\.json$/i, '');
|
|
||||||
const extension = '.json';
|
|
||||||
const devicePart = `_${deviceIdHash}`;
|
|
||||||
const timestampPart = `_${timestamp}${counterSuffix}`;
|
|
||||||
|
|
||||||
const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length;
|
|
||||||
|
|
||||||
if (totalLength > 200) {
|
|
||||||
const availableLength = 200 - devicePart.length - timestampPart.length - extension.length;
|
|
||||||
const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength));
|
|
||||||
return `${finalBase}${devicePart}${timestampPart}${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets hashed device identifier
|
|
||||||
*/
|
|
||||||
private getHashedDeviceIdentifierElectron(): string {
|
|
||||||
try {
|
|
||||||
const deviceInfo = this.getDeviceInfoElectron();
|
|
||||||
return this.hashStringElectron(deviceInfo);
|
|
||||||
} catch (error) {
|
|
||||||
return 'electron';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets device info string
|
|
||||||
*/
|
|
||||||
private getDeviceInfoElectron(): string {
|
|
||||||
try {
|
|
||||||
// Use machine-specific information
|
|
||||||
const os = require('os');
|
|
||||||
const hostname = os.hostname() || 'unknown';
|
|
||||||
const platform = os.platform() || 'unknown';
|
|
||||||
const arch = os.arch() || 'unknown';
|
|
||||||
|
|
||||||
// Create device info string
|
|
||||||
return `${platform}_${hostname}_${arch}`;
|
|
||||||
} catch (error) {
|
|
||||||
return 'electron';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple hash function for device ID
|
|
||||||
*/
|
|
||||||
private hashStringElectron(str: string): string {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
const char = str.charCodeAt(i);
|
|
||||||
hash = ((hash << 5) - hash) + char;
|
|
||||||
hash = hash & hash;
|
|
||||||
}
|
|
||||||
return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if running on Capacitor platform.
|
* Checks if running on Capacitor platform.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "../PlatformService";
|
} from "../PlatformService";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { QueryExecResult } from "@/interfaces/database";
|
import { QueryExecResult } from "@/interfaces/database";
|
||||||
|
import { BaseDatabaseService } from "./BaseDatabaseService";
|
||||||
// Dynamic import of initBackend to prevent worker context errors
|
// Dynamic import of initBackend to prevent worker context errors
|
||||||
import type {
|
import type {
|
||||||
WorkerRequest,
|
WorkerRequest,
|
||||||
@@ -29,7 +30,10 @@ import type {
|
|||||||
* Note: File system operations are not available in the web platform
|
* Note: File system operations are not available in the web platform
|
||||||
* due to browser security restrictions. These methods throw appropriate errors.
|
* due to browser security restrictions. These methods throw appropriate errors.
|
||||||
*/
|
*/
|
||||||
export class WebPlatformService implements PlatformService {
|
export class WebPlatformService
|
||||||
|
extends BaseDatabaseService
|
||||||
|
implements PlatformService
|
||||||
|
{
|
||||||
private static instanceCount = 0; // Debug counter
|
private static instanceCount = 0; // Debug counter
|
||||||
private worker: Worker | null = null;
|
private worker: Worker | null = null;
|
||||||
private workerReady = false;
|
private workerReady = false;
|
||||||
@@ -46,17 +50,16 @@ export class WebPlatformService implements PlatformService {
|
|||||||
private readonly messageTimeout = 30000; // 30 seconds
|
private readonly messageTimeout = 30000; // 30 seconds
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
super();
|
||||||
WebPlatformService.instanceCount++;
|
WebPlatformService.instanceCount++;
|
||||||
|
|
||||||
// Use debug level logging for development mode to reduce console noise
|
logger.debug("[WebPlatformService] Initializing web platform service");
|
||||||
const isDevelopment = process.env.VITE_PLATFORM === "development";
|
|
||||||
const log = isDevelopment ? logger.debug : logger.log;
|
|
||||||
|
|
||||||
log("[WebPlatformService] Initializing web platform service");
|
|
||||||
|
|
||||||
// Only initialize SharedArrayBuffer setup for web platforms
|
// Only initialize SharedArrayBuffer setup for web platforms
|
||||||
if (this.isWorker()) {
|
if (this.isWorker()) {
|
||||||
log("[WebPlatformService] Skipping initBackend call in worker context");
|
logger.debug(
|
||||||
|
"[WebPlatformService] Skipping initBackend call in worker context",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +100,9 @@ export class WebPlatformService implements PlatformService {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// We're in a worker context - skip initBackend call
|
// We're in a worker context - skip initBackend call
|
||||||
logger.info(
|
// Use console for critical startup message to avoid circular dependency
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
"[WebPlatformService] Skipping initBackend call in worker context",
|
"[WebPlatformService] Skipping initBackend call in worker context",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -592,157 +597,6 @@ export class WebPlatformService implements PlatformService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves content directly to the device's Downloads folder (Android) or Documents folder (iOS).
|
|
||||||
* Uses MediaStore on Android API 29+ and falls back to SAF on older versions.
|
|
||||||
*
|
|
||||||
* @param fileName - The filename of the file to save
|
|
||||||
* @param content - The content to write to the file
|
|
||||||
* @returns Promise that resolves when the file is saved
|
|
||||||
*/
|
|
||||||
async saveToDevice(fileName: string, content: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Ensure content is valid JSON
|
|
||||||
try {
|
|
||||||
JSON.parse(content);
|
|
||||||
} catch {
|
|
||||||
throw new Error('Content must be valid JSON');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique filename
|
|
||||||
const uniqueFileName = this.generateUniqueFileName(fileName);
|
|
||||||
|
|
||||||
// Web platform: Use the same download mechanism as writeAndShareFile
|
|
||||||
await this.writeAndShareFile(uniqueFileName, content);
|
|
||||||
logger.log("[WebPlatformService] File saved to device", uniqueFileName);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("[WebPlatformService] Error saving file to device", error);
|
|
||||||
throw new Error(
|
|
||||||
`Failed to save file to device: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the system file picker to let the user choose where to save a file (web platform).
|
|
||||||
* Uses the browser's download mechanism with a suggested filename.
|
|
||||||
*
|
|
||||||
* @param fileName - The suggested filename for the file
|
|
||||||
* @param content - The content to write to the file
|
|
||||||
* @returns Promise that resolves when the file is saved
|
|
||||||
*/
|
|
||||||
async saveAs(fileName: string, content: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Ensure content is valid JSON
|
|
||||||
try {
|
|
||||||
JSON.parse(content);
|
|
||||||
} catch {
|
|
||||||
throw new Error('Content must be valid JSON');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique filename
|
|
||||||
const uniqueFileName = this.generateUniqueFileName(fileName);
|
|
||||||
|
|
||||||
// Web platform: Use the same download mechanism as writeAndShareFile
|
|
||||||
await this.writeAndShareFile(uniqueFileName, content);
|
|
||||||
logger.log("[WebPlatformService] File saved as", uniqueFileName);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("[WebPlatformService] Error saving file as", error);
|
|
||||||
throw new Error(
|
|
||||||
`Failed to save file as: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates unique filename with timestamp, hashed device ID, and counter
|
|
||||||
*/
|
|
||||||
private generateUniqueFileName(baseName: string, counter = 0): string {
|
|
||||||
const now = new Date();
|
|
||||||
const timestamp = now.toISOString()
|
|
||||||
.replace(/[:.]/g, '-')
|
|
||||||
.replace('T', '_')
|
|
||||||
.replace('Z', '');
|
|
||||||
|
|
||||||
const deviceIdHash = this.getHashedDeviceIdentifier();
|
|
||||||
const counterSuffix = counter > 0 ? `_${counter}` : '';
|
|
||||||
|
|
||||||
const maxBaseLength = 45;
|
|
||||||
const truncatedBase = baseName.length > maxBaseLength
|
|
||||||
? baseName.substring(0, maxBaseLength)
|
|
||||||
: baseName;
|
|
||||||
|
|
||||||
const nameWithoutExt = truncatedBase.replace(/\.json$/i, '');
|
|
||||||
const extension = '.json';
|
|
||||||
const devicePart = `_${deviceIdHash}`;
|
|
||||||
const timestampPart = `_${timestamp}${counterSuffix}`;
|
|
||||||
|
|
||||||
const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length;
|
|
||||||
|
|
||||||
if (totalLength > 200) {
|
|
||||||
const availableLength = 200 - devicePart.length - timestampPart.length - extension.length;
|
|
||||||
const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength));
|
|
||||||
return `${finalBase}${devicePart}${timestampPart}${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets hashed device identifier
|
|
||||||
*/
|
|
||||||
private getHashedDeviceIdentifier(): string {
|
|
||||||
try {
|
|
||||||
const deviceInfo = this.getDeviceInfo();
|
|
||||||
return this.hashString(deviceInfo);
|
|
||||||
} catch (error) {
|
|
||||||
return 'web';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets device info string
|
|
||||||
*/
|
|
||||||
private getDeviceInfo(): string {
|
|
||||||
try {
|
|
||||||
// Use browser fingerprint or fallback
|
|
||||||
const userAgent = navigator.userAgent;
|
|
||||||
const language = navigator.language || 'unknown';
|
|
||||||
const platform = navigator.platform || 'unknown';
|
|
||||||
|
|
||||||
let browser = 'unknown';
|
|
||||||
let os = 'unknown';
|
|
||||||
|
|
||||||
if (userAgent.includes('Chrome')) browser = 'chrome';
|
|
||||||
else if (userAgent.includes('Firefox')) browser = 'firefox';
|
|
||||||
else if (userAgent.includes('Safari')) browser = 'safari';
|
|
||||||
else if (userAgent.includes('Edge')) browser = 'edge';
|
|
||||||
|
|
||||||
if (userAgent.includes('Windows')) os = 'win';
|
|
||||||
else if (userAgent.includes('Mac')) os = 'mac';
|
|
||||||
else if (userAgent.includes('Linux')) os = 'linux';
|
|
||||||
else if (userAgent.includes('Android')) os = 'android';
|
|
||||||
else if (userAgent.includes('iOS')) os = 'ios';
|
|
||||||
|
|
||||||
return `${browser}_${os}_${platform}_${language}`;
|
|
||||||
} catch (error) {
|
|
||||||
return 'web';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple hash function for device ID
|
|
||||||
*/
|
|
||||||
private hashString(str: string): string {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
const char = str.charCodeAt(i);
|
|
||||||
hash = ((hash << 5) - hash) + char;
|
|
||||||
hash = hash & hash;
|
|
||||||
}
|
|
||||||
return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see PlatformService.dbQuery
|
* @see PlatformService.dbQuery
|
||||||
*/
|
*/
|
||||||
@@ -819,105 +673,8 @@ export class WebPlatformService implements PlatformService {
|
|||||||
// SharedArrayBuffer initialization is handled by initBackend call in initializeWorker
|
// SharedArrayBuffer initialization is handled by initBackend call in initializeWorker
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database utility methods
|
// Database utility methods - inherited from BaseDatabaseService
|
||||||
generateInsertStatement(
|
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
|
||||||
model: Record<string, unknown>,
|
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
|
||||||
tableName: string,
|
// retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService
|
||||||
): { sql: string; params: unknown[] } {
|
|
||||||
const keys = Object.keys(model);
|
|
||||||
const placeholders = keys.map(() => "?").join(", ");
|
|
||||||
const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`;
|
|
||||||
const params = keys.map((key) => model[key]);
|
|
||||||
return { sql, params };
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateDefaultSettings(
|
|
||||||
settings: Record<string, unknown>,
|
|
||||||
): Promise<void> {
|
|
||||||
// Get current active DID and update that identity's settings
|
|
||||||
const activeIdentity = await this.getActiveIdentity();
|
|
||||||
const activeDid = activeIdentity.activeDid;
|
|
||||||
|
|
||||||
if (!activeDid) {
|
|
||||||
logger.warn(
|
|
||||||
"[WebPlatformService] No active DID found, cannot update default settings",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = Object.keys(settings);
|
|
||||||
const setClause = keys.map((key) => `${key} = ?`).join(", ");
|
|
||||||
const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`;
|
|
||||||
const params = [...keys.map((key) => settings[key]), activeDid];
|
|
||||||
await this.dbExec(sql, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateActiveDid(did: string): Promise<void> {
|
|
||||||
await this.dbExec(
|
|
||||||
"INSERT OR REPLACE INTO active_identity (id, activeDid, lastUpdated) VALUES (1, ?, ?)",
|
|
||||||
[did, new Date().toISOString()],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getActiveIdentity(): Promise<{ activeDid: string }> {
|
|
||||||
const result = await this.dbQuery(
|
|
||||||
"SELECT activeDid FROM active_identity WHERE id = 1",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
activeDid: (result?.values?.[0]?.[0] as string) || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async insertNewDidIntoSettings(did: string): Promise<void> {
|
|
||||||
// Import constants dynamically to avoid circular dependencies
|
|
||||||
const { DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER } =
|
|
||||||
await import("@/constants/app");
|
|
||||||
|
|
||||||
// Use INSERT OR REPLACE to handle case where settings already exist for this DID
|
|
||||||
// This prevents duplicate accountDid entries and ensures data integrity
|
|
||||||
await this.dbExec(
|
|
||||||
"INSERT OR REPLACE INTO settings (accountDid, finishedOnboarding, apiServer, partnerApiServer) VALUES (?, ?, ?, ?)",
|
|
||||||
[did, false, DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateDidSpecificSettings(
|
|
||||||
did: string,
|
|
||||||
settings: Record<string, unknown>,
|
|
||||||
): Promise<void> {
|
|
||||||
const keys = Object.keys(settings);
|
|
||||||
const setClause = keys.map((key) => `${key} = ?`).join(", ");
|
|
||||||
const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`;
|
|
||||||
const params = [...keys.map((key) => settings[key]), did];
|
|
||||||
// Log update operation for debugging
|
|
||||||
logger.debug(
|
|
||||||
"[WebPlatformService] updateDidSpecificSettings",
|
|
||||||
sql,
|
|
||||||
JSON.stringify(params, null, 2),
|
|
||||||
);
|
|
||||||
await this.dbExec(sql, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
async retrieveSettingsForActiveAccount(): Promise<Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
> | null> {
|
|
||||||
const result = await this.dbQuery("SELECT * FROM settings WHERE id = 1");
|
|
||||||
if (result?.values?.[0]) {
|
|
||||||
// Convert the row to an object
|
|
||||||
const row = result.values[0];
|
|
||||||
const columns = result.columns || [];
|
|
||||||
const settings: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
columns.forEach((column, index) => {
|
|
||||||
if (column !== "id") {
|
|
||||||
// Exclude the id column
|
|
||||||
settings[column] = row[index];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1488,18 +1488,21 @@ export default class AccountViewView extends Vue {
|
|||||||
status?: number;
|
status?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
logger.error("[Server Limits] Error retrieving limits:", {
|
logger.warn(
|
||||||
error: error instanceof Error ? error.message : String(error),
|
"[Server Limits] Error retrieving limits, expected for unregistered users:",
|
||||||
did: did,
|
{
|
||||||
apiServer: this.apiServer,
|
error: error instanceof Error ? error.message : String(error),
|
||||||
imageServer: this.DEFAULT_IMAGE_API_SERVER,
|
did: did,
|
||||||
partnerApiServer: this.partnerApiServer,
|
apiServer: this.apiServer,
|
||||||
errorCode: axiosError?.response?.data?.error?.code,
|
imageServer: this.DEFAULT_IMAGE_API_SERVER,
|
||||||
errorMessage: axiosError?.response?.data?.error?.message,
|
partnerApiServer: this.partnerApiServer,
|
||||||
httpStatus: axiosError?.response?.status,
|
errorCode: axiosError?.response?.data?.error?.code,
|
||||||
needsUserMigration: true,
|
errorMessage: axiosError?.response?.data?.error?.message,
|
||||||
timestamp: new Date().toISOString(),
|
httpStatus: axiosError?.response?.status,
|
||||||
});
|
needsUserMigration: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// this.notify.error(this.limitsMessage, TIMEOUTS.STANDARD);
|
// this.notify.error(this.limitsMessage, TIMEOUTS.STANDARD);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -91,12 +91,15 @@
|
|||||||
<div class="text-sm overflow-hidden">
|
<div class="text-sm overflow-hidden">
|
||||||
<div
|
<div
|
||||||
data-testId="description"
|
data-testId="description"
|
||||||
class="overflow-hidden text-ellipsis"
|
class="flex items-start gap-2 overflow-hidden"
|
||||||
>
|
>
|
||||||
<font-awesome icon="message" class="fa-fw text-slate-400" />
|
<font-awesome
|
||||||
|
icon="message"
|
||||||
|
class="fa-fw text-slate-400 flex-shrink-0 mt-1"
|
||||||
|
/>
|
||||||
<vue-markdown
|
<vue-markdown
|
||||||
:source="claimDescription"
|
:source="claimDescription"
|
||||||
class="markdown-content"
|
class="markdown-content flex-1 min-w-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-hidden text-ellipsis">
|
<div class="overflow-hidden text-ellipsis">
|
||||||
@@ -551,7 +554,7 @@ import VueMarkdown from "vue-markdown-render";
|
|||||||
import { Router, RouteLocationNormalizedLoaded } from "vue-router";
|
import { Router, RouteLocationNormalizedLoaded } from "vue-router";
|
||||||
import { copyToClipboard } from "../services/ClipboardService";
|
import { copyToClipboard } from "../services/ClipboardService";
|
||||||
|
|
||||||
import { GenericVerifiableCredential } from "../interfaces";
|
import { EmojiClaim, GenericVerifiableCredential } from "../interfaces";
|
||||||
import GiftedDialog from "../components/GiftedDialog.vue";
|
import GiftedDialog from "../components/GiftedDialog.vue";
|
||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import { NotificationIface } from "../constants/app";
|
import { NotificationIface } from "../constants/app";
|
||||||
@@ -667,6 +670,10 @@ export default class ClaimView extends Vue {
|
|||||||
return giveClaim.description || "";
|
return giveClaim.description || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.veriClaim.claimType === "Emoji") {
|
||||||
|
return (claim as EmojiClaim).text || "";
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback for other claim types
|
// Fallback for other claim types
|
||||||
return (claim as { description?: string })?.description || "";
|
return (claim as { description?: string })?.description || "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -536,6 +536,14 @@ export default class DiscoverView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async searchAll(beforeId?: string) {
|
public async searchAll(beforeId?: string) {
|
||||||
|
// Guard against concurrent calls (allow pagination concurrent calls)
|
||||||
|
if (this.isLoading && !beforeId) {
|
||||||
|
logger.debug(
|
||||||
|
"[DiscoverView] ⚠️ searchAll() already in progress, skipping",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.resetCounts();
|
this.resetCounts();
|
||||||
|
|
||||||
if (!beforeId) {
|
if (!beforeId) {
|
||||||
@@ -601,6 +609,14 @@ export default class DiscoverView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async searchStarred() {
|
public async searchStarred() {
|
||||||
|
// Guard against concurrent calls
|
||||||
|
if (this.isLoading) {
|
||||||
|
logger.debug(
|
||||||
|
"[DiscoverView] ⚠️ searchStarred() already in progress, skipping",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.resetCounts();
|
this.resetCounts();
|
||||||
|
|
||||||
// Clear any previous results
|
// Clear any previous results
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ Raymer * @version 1.0.0 */
|
|||||||
:last-viewed-claim-id="feedLastViewedClaimId"
|
:last-viewed-claim-id="feedLastViewedClaimId"
|
||||||
:is-registered="isRegistered"
|
:is-registered="isRegistered"
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
|
:api-server="apiServer"
|
||||||
@load-claim="onClickLoadClaim"
|
@load-claim="onClickLoadClaim"
|
||||||
@view-image="openImageViewer"
|
@view-image="openImageViewer"
|
||||||
/>
|
/>
|
||||||
@@ -705,7 +706,7 @@ export default class HomeView extends Vue {
|
|||||||
};
|
};
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"[HomeView Settings Trace] ⚠️ Registration check failed",
|
"[HomeView Settings Trace] ⚠️ Registration check failed, expected for unregistered users.",
|
||||||
{
|
{
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
did: this.activeDid,
|
did: this.activeDid,
|
||||||
@@ -1090,17 +1091,27 @@ export default class HomeView extends Vue {
|
|||||||
* - this.feedData (via processFeedResults)
|
* - this.feedData (via processFeedResults)
|
||||||
* - this.feedLastViewedClaimId (via updateFeedLastViewedId)
|
* - this.feedLastViewedClaimId (via updateFeedLastViewedId)
|
||||||
*/
|
*/
|
||||||
async updateAllFeed() {
|
async updateAllFeed(retryCount: number = 0) {
|
||||||
|
// Guard against concurrent calls (but allow retries)
|
||||||
|
if (this.isFeedLoading && retryCount === 0) {
|
||||||
|
logger.debug(
|
||||||
|
"[HomeView] ⚠️ updateAllFeed() already in progress, skipping",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug("[HomeView] 🚀 updateAllFeed() called", {
|
logger.debug("[HomeView] 🚀 updateAllFeed() called", {
|
||||||
isFeedLoading: this.isFeedLoading,
|
isFeedLoading: this.isFeedLoading,
|
||||||
currentFeedDataLength: this.feedData.length,
|
currentFeedDataLength: this.feedData.length,
|
||||||
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
|
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
|
||||||
isFeedFilteredByVisible: this.isFeedFilteredByVisible,
|
isFeedFilteredByVisible: this.isFeedFilteredByVisible,
|
||||||
isFeedFilteredByNearby: this.isFeedFilteredByNearby,
|
isFeedFilteredByNearby: this.isFeedFilteredByNearby,
|
||||||
|
retryCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.isFeedLoading = true;
|
this.isFeedLoading = true;
|
||||||
let endOfResults = true;
|
let endOfResults = true;
|
||||||
|
const MAX_RETRIES = 5; // Prevent infinite recursion
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await this.retrieveGives(
|
const results = await this.retrieveGives(
|
||||||
@@ -1126,11 +1137,24 @@ export default class HomeView extends Vue {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("[HomeView] ❌ Error in updateAllFeed:", e);
|
logger.error("[HomeView] ❌ Error in updateAllFeed:", e);
|
||||||
this.handleFeedError(e);
|
this.handleFeedError(e);
|
||||||
|
// Don't retry on error
|
||||||
|
endOfResults = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.feedData.length === 0 && !endOfResults) {
|
// Fixed recursive retry with guard and retry count
|
||||||
logger.debug("[HomeView] 🔄 No results after filtering, retrying...");
|
if (
|
||||||
await this.updateAllFeed();
|
this.feedData.length === 0 &&
|
||||||
|
!endOfResults &&
|
||||||
|
retryCount < MAX_RETRIES
|
||||||
|
) {
|
||||||
|
logger.debug("[HomeView] 🔄 No results after filtering, retrying...", {
|
||||||
|
retryCount: retryCount + 1,
|
||||||
|
maxRetries: MAX_RETRIES,
|
||||||
|
});
|
||||||
|
// Temporarily clear loading flag for recursive call
|
||||||
|
this.isFeedLoading = false;
|
||||||
|
await this.updateAllFeed(retryCount + 1);
|
||||||
|
return; // Exit after recursive call
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isFeedLoading = false;
|
this.isFeedLoading = false;
|
||||||
@@ -1264,6 +1288,7 @@ export default class HomeView extends Vue {
|
|||||||
provider,
|
provider,
|
||||||
fulfillsPlan,
|
fulfillsPlan,
|
||||||
providedByPlan,
|
providedByPlan,
|
||||||
|
record.emojiCount,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1487,12 +1512,14 @@ export default class HomeView extends Vue {
|
|||||||
provider: Provider | undefined,
|
provider: Provider | undefined,
|
||||||
fulfillsPlan?: FulfillsPlan,
|
fulfillsPlan?: FulfillsPlan,
|
||||||
providedByPlan?: ProvidedByPlan,
|
providedByPlan?: ProvidedByPlan,
|
||||||
|
emojiCount?: Record<string, number>,
|
||||||
): GiveRecordWithContactInfo {
|
): GiveRecordWithContactInfo {
|
||||||
return {
|
return {
|
||||||
...record,
|
...record,
|
||||||
jwtId: record.jwtId,
|
jwtId: record.jwtId,
|
||||||
fullClaim: record.fullClaim,
|
fullClaim: record.fullClaim,
|
||||||
description: record.description || "",
|
description: record.description || "",
|
||||||
|
emojiCount: emojiCount || {},
|
||||||
handleId: record.handleId,
|
handleId: record.handleId,
|
||||||
issuerDid: record.issuerDid,
|
issuerDid: record.issuerDid,
|
||||||
fulfillsPlanHandleId: record.fulfillsPlanHandleId,
|
fulfillsPlanHandleId: record.fulfillsPlanHandleId,
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ export async function importUserFromAccount(page: Page, id?: string): Promise<st
|
|||||||
|
|
||||||
await page.getByRole("button", { name: "Import" }).click();
|
await page.getByRole("button", { name: "Import" }).click();
|
||||||
|
|
||||||
|
// PHASE 1 FIX: Wait for registration status to settle
|
||||||
|
// This ensures that components have the correct isRegistered status
|
||||||
|
await waitForRegistrationStatusToSettle(page);
|
||||||
|
|
||||||
return userZeroData.did;
|
return userZeroData.did;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +73,11 @@ export async function importUser(page: Page, id?: string): Promise<string> {
|
|||||||
await expect(
|
await expect(
|
||||||
page.locator("#sectionUsageLimits").getByText("Checking")
|
page.locator("#sectionUsageLimits").getByText("Checking")
|
||||||
).toBeHidden();
|
).toBeHidden();
|
||||||
|
|
||||||
|
// PHASE 1 FIX: Wait for registration check to complete and update UI elements
|
||||||
|
// This ensures that components like InviteOneView have the correct isRegistered status
|
||||||
|
await waitForRegistrationStatusToSettle(page);
|
||||||
|
|
||||||
return did;
|
return did;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,3 +346,78 @@ export function getElementWaitTimeout(): number {
|
|||||||
export function getPageLoadTimeout(): number {
|
export function getPageLoadTimeout(): number {
|
||||||
return getAdaptiveTimeout(30000, 1.4);
|
return getAdaptiveTimeout(30000, 1.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHASE 1 FIX: Wait for registration status to settle
|
||||||
|
*
|
||||||
|
* This function addresses the timing issue where:
|
||||||
|
* 1. User imports identity → Database shows isRegistered: false
|
||||||
|
* 2. HomeView loads → Starts async registration check
|
||||||
|
* 3. Other views load → Use cached isRegistered: false
|
||||||
|
* 4. Async check completes → Updates database to isRegistered: true
|
||||||
|
* 5. But other views don't re-check → Plus buttons don't appear
|
||||||
|
*
|
||||||
|
* This function waits for the async registration check to complete
|
||||||
|
* without interfering with test navigation.
|
||||||
|
*/
|
||||||
|
export async function waitForRegistrationStatusToSettle(page: Page): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Wait for the initial registration check to complete
|
||||||
|
// This is indicated by the "Checking" text disappearing from usage limits
|
||||||
|
await expect(
|
||||||
|
page.locator("#sectionUsageLimits").getByText("Checking")
|
||||||
|
).toBeHidden({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Before navigating back to the page, we'll trigger a registration check
|
||||||
|
// by navigating to home and waiting for the registration process to complete
|
||||||
|
|
||||||
|
const currentUrl = page.url();
|
||||||
|
|
||||||
|
// Navigate to home to trigger the registration check
|
||||||
|
await page.goto('./');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Wait for the registration check to complete by monitoring the usage limits section
|
||||||
|
// This ensures the async registration check has finished
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const usageLimits = document.querySelector('#sectionUsageLimits');
|
||||||
|
if (!usageLimits) return true; // No usage limits section, assume ready
|
||||||
|
|
||||||
|
// Check if the "Checking..." spinner is gone
|
||||||
|
const checkingSpinner = usageLimits.querySelector('.fa-spin');
|
||||||
|
if (checkingSpinner) return false; // Still loading
|
||||||
|
|
||||||
|
// Check if we have actual content (not just the spinner)
|
||||||
|
const hasContent = usageLimits.querySelector('p') || usageLimits.querySelector('button');
|
||||||
|
return hasContent !== null; // Has actual content, not just spinner
|
||||||
|
}, { timeout: 10000 });
|
||||||
|
|
||||||
|
// Also navigate to account page to ensure activeDid is set and usage limits are loaded
|
||||||
|
await page.goto('./account');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Wait for the usage limits section to be visible and loaded
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const usageLimits = document.querySelector('#sectionUsageLimits');
|
||||||
|
if (!usageLimits) return false; // Section should exist on account page
|
||||||
|
|
||||||
|
// Check if the "Checking..." spinner is gone
|
||||||
|
const checkingSpinner = usageLimits.querySelector('.fa-spin');
|
||||||
|
if (checkingSpinner) return false; // Still loading
|
||||||
|
|
||||||
|
// Check if we have actual content (not just the spinner)
|
||||||
|
const hasContent = usageLimits.querySelector('p') || usageLimits.querySelector('button');
|
||||||
|
return hasContent !== null; // Has actual content, not just spinner
|
||||||
|
}, { timeout: 15000 });
|
||||||
|
|
||||||
|
// Navigate back to the original page if it wasn't home
|
||||||
|
if (!currentUrl.includes('/')) {
|
||||||
|
await page.goto(currentUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Registration status check timed out, continuing anyway
|
||||||
|
// This may indicate the user is not registered or there's a server issue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user