Skip to content

Commit cfbf9d0

Browse files
committed
feat: session cookies
1 parent 72f8820 commit cfbf9d0

7 files changed

Lines changed: 120 additions & 22 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ API, SQLite storage, local file uploads, and an optional Capacitor mobile shell.
1414

1515
- Own your data: production data lives in a local SQLite database under `/data`.
1616
- Import cards quickly: collection imports accept text and CSV/TXT files,
17-
including files shared directly into the native Android and iOS shells.
17+
including Android shares/open-with files and iOS shares sent into the native
18+
shells.
1819
- Treat decks as physical commitments: deck cards can be allocated to concrete
1920
collection items so one copy cannot accidentally be promised to multiple decks.
2021
- Turn gaps into action: missing-card reports and buylist exports show what a
@@ -101,9 +102,10 @@ and native API work. The native shell starts from bundled setup assets in
101102
`native_www`, asks for the ManaVault server URL on first launch, stores that URL
102103
on the device, checks the latest GitHub release for APK updates, and then loads
103104
the configured server inside the native WebView.
104-
The native shells also register as text/CSV share targets; on Android, pick
105-
ManaVault from the sharesheet/export flow to open shared text or CSV/TXT files
106-
in the collection import flow.
105+
The native shells register as text/CSV share targets. On Android, ManaVault also
106+
accepts `content://` or `file://` open-with file intents; pick ManaVault with
107+
the Import action label beneath the app name from the Share, Open with, or
108+
Export file flow to send TXT/CSV exports into collection import with auto-preview.
107109

108110
Use `aube` for JavaScript package tasks:
109111

‎android/app/src/main/AndroidManifest.xml‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
<data android:scheme="manavault" />
3939
</intent-filter>
4040

41-
<intent-filter>
41+
<intent-filter android:label="@string/intent_action_import">
4242
<action android:name="android.intent.action.SEND" />
4343
<category android:name="android.intent.category.DEFAULT" />
4444
<data android:mimeType="text/plain" />
@@ -49,7 +49,7 @@
4949
<data android:mimeType="application/vnd.ms-excel" />
5050
</intent-filter>
5151

52-
<intent-filter>
52+
<intent-filter android:label="@string/intent_action_import">
5353
<action android:name="android.intent.action.SEND_MULTIPLE" />
5454
<category android:name="android.intent.category.DEFAULT" />
5555
<data android:mimeType="text/plain" />
@@ -60,6 +60,19 @@
6060
<data android:mimeType="application/vnd.ms-excel" />
6161
</intent-filter>
6262

63+
<intent-filter android:label="@string/intent_action_import">
64+
<action android:name="android.intent.action.VIEW" />
65+
<category android:name="android.intent.category.DEFAULT" />
66+
<data android:scheme="content" />
67+
<data android:scheme="file" />
68+
<data android:mimeType="text/plain" />
69+
<data android:mimeType="text/*" />
70+
<data android:mimeType="text/csv" />
71+
<data android:mimeType="text/comma-separated-values" />
72+
<data android:mimeType="application/csv" />
73+
<data android:mimeType="application/vnd.ms-excel" />
74+
</intent-filter>
75+
6376
</activity>
6477

6578
<provider

‎android/app/src/main/java/dev/cfb/manavault/SharedImportPlugin.java‎

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.io.InputStream;
2020
import java.nio.charset.StandardCharsets;
2121
import java.util.ArrayList;
22+
import java.util.Locale;
2223

2324
@CapacitorPlugin(name = "SharedImport")
2425
public class SharedImportPlugin extends Plugin {
@@ -79,8 +80,7 @@ private JSObject payloadFromIntent(Context context, Intent intent) {
7980

8081
String action = intent.getAction();
8182
if (Intent.ACTION_VIEW.equals(action)) {
82-
Uri data = intent.getData();
83-
return isManaVaultLink(context, data) ? linkPayload(data.toString(), "android-view") : null;
83+
return payloadFromViewIntent(context, intent);
8484
}
8585

8686
if (!Intent.ACTION_SEND.equals(action) && !Intent.ACTION_SEND_MULTIPLE.equals(action)) return null;
@@ -111,6 +111,75 @@ private JSObject payloadFromIntent(Context context, Intent intent) {
111111
return importPayload(text, "Shared list.txt", intent.getType(), "android-share");
112112
}
113113

114+
private JSObject payloadFromViewIntent(Context context, Intent intent) {
115+
Uri data = intent.getData();
116+
if (isManaVaultLink(context, data)) return linkPayload(data.toString(), "android-view");
117+
if (!isViewFileUri(data)) return null;
118+
119+
ContentResolver resolver = context.getContentResolver();
120+
String intentType = normalizeMimeType(intent.getType());
121+
String resolverType = normalizeMimeType(resolverMimeType(resolver, data));
122+
boolean supportedType = isSupportedTextFileMimeType(intentType) || isSupportedTextFileMimeType(resolverType);
123+
124+
String fileName = null;
125+
if (!supportedType && !hasTextFileExtension(data.getLastPathSegment())) {
126+
fileName = displayName(resolver, data);
127+
supportedType = hasTextFileExtension(fileName);
128+
}
129+
if (!supportedType) return null;
130+
131+
String text = readText(resolver, data);
132+
if (text == null || text.trim().isEmpty()) return null;
133+
134+
if (fileName == null) fileName = displayName(resolver, data);
135+
return importPayload(text, fileName, bestMimeType(intentType, resolverType), "android-view-file");
136+
}
137+
138+
private boolean isViewFileUri(Uri uri) {
139+
if (uri == null) return false;
140+
141+
String scheme = uri.getScheme();
142+
return "content".equalsIgnoreCase(scheme) || "file".equalsIgnoreCase(scheme);
143+
}
144+
145+
private String resolverMimeType(ContentResolver resolver, Uri uri) {
146+
try {
147+
return resolver.getType(uri);
148+
} catch (SecurityException e) {
149+
return null;
150+
}
151+
}
152+
153+
private String normalizeMimeType(String mimeType) {
154+
if (mimeType == null) return null;
155+
156+
String normalized = mimeType.trim().toLowerCase(Locale.ROOT);
157+
int parameters = normalized.indexOf(';');
158+
return parameters >= 0 ? normalized.substring(0, parameters).trim() : normalized;
159+
}
160+
161+
private boolean isSupportedTextFileMimeType(String mimeType) {
162+
if (mimeType == null || mimeType.trim().isEmpty()) return false;
163+
164+
return mimeType.startsWith("text/")
165+
|| "application/csv".equals(mimeType)
166+
|| "application/vnd.ms-excel".equals(mimeType);
167+
}
168+
169+
private boolean hasTextFileExtension(String value) {
170+
if (value == null) return false;
171+
172+
String lower = value.trim().toLowerCase(Locale.ROOT);
173+
return lower.endsWith(".txt") || lower.endsWith(".csv");
174+
}
175+
176+
private String bestMimeType(String intentType, String resolverType) {
177+
if (isSupportedTextFileMimeType(intentType)) return intentType;
178+
if (isSupportedTextFileMimeType(resolverType)) return resolverType;
179+
if (intentType != null && !intentType.trim().isEmpty()) return intentType;
180+
return resolverType;
181+
}
182+
114183
@SuppressWarnings("deprecation")
115184
private Uri firstStreamUri(Intent intent) {
116185
Object stream = intent.getParcelableExtra(Intent.EXTRA_STREAM);

‎android/app/src/main/res/values/strings.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
<resources>
33
<string name="app_name">ManaVault</string>
44
<string name="title_activity_main">ManaVault</string>
5+
<string name="intent_action_import">Import</string>
56
<string name="package_name">dev.cfb.manavault</string>
67
<string name="custom_url_scheme">dev.cfb.manavault</string>
78
</resources>

‎docs/android.md‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Android App Builds
22

33
ManaVault publishes an Android APK from GitHub releases. That official APK is
4-
signed with the ManaVault release key, accepts Android shares of text/CSV files,
5-
and can load any ManaVault server URL after first launch.
4+
signed with the ManaVault release key, accepts Android text/CSV shares and
5+
open-with file intents, and can load any ManaVault server URL after first launch.
66

77
Verified Android App Links are navigation intents: Android only opens
88
`https://...` links in an app when the APK declares that exact host and the
@@ -14,29 +14,31 @@ website publishes the APK signing certificate fingerprint at
1414
Use the release APK from ManaVault GitHub releases when you only need:
1515

1616
- entering your self-hosted ManaVault URL in the app on first launch
17-
- Android sharesheet/export imports from apps like ManaBox
17+
- Android Share/Open with/Export file imports from apps like ManaBox
1818
- `manavault://...` links
1919
- verified links for `https://manavault.cfb.dev/...`
2020

2121
No custom Android build is needed for that flow.
2222

2323
## Import From ManaBox
2424

25-
In ManaBox, export or share the collection as CSV, custom text, or a TXT/CSV
26-
file through Android's sharesheet, then choose the ManaVault native app.
27-
ManaVault receives that native share and opens the collection import dialog with
28-
the shared text or file contents.
25+
In ManaBox, export the collection as CSV, custom text, or a TXT/CSV file, then
26+
use Android's Share, Open with, or Export file flow and choose ManaVault with
27+
the Import action label beneath the app name.
28+
ManaVault accepts text/CSV Android shares and `content://` or `file://`
29+
open-with file intents, then opens the collection import dialog with the shared
30+
text or file contents and previews the import.
2931

30-
Do not use a ManaVault HTTPS page opened inside ManaBox's embedded browser for
31-
import. A page loaded in another app's WebView is not an Android
32-
`ACTION_SEND`, `ACTION_SEND_MULTIPLE`, or `ACTION_VIEW` intent delivered to
33-
ManaVault, so ManaVault cannot force native ingest from that path.
32+
If ManaBox opens a ManaVault HTTPS page inside its own embedded browser, back
33+
out and choose the Android resolver/share target instead. That WebView path is
34+
not an `ACTION_SEND`, `ACTION_SEND_MULTIPLE`, or `ACTION_VIEW` intent delivered
35+
to ManaVault, so ManaVault cannot ingest the export from there.
3436

3537
`manavault://collection?importFile=true`,
3638
`manavault:///collection?importFile=true`, verified App Links, and hosted or
3739
self-hosted `https://...` ManaVault URLs are navigation links. They can open the
38-
app route, but they do not carry a ManaBox export file; use the sharesheet or
39-
export-file path for imports.
40+
app route, but they do not carry a ManaBox export file; use the Android
41+
Share/Open with/Export file path for imports.
4042

4143
## Build An APK For A Custom Domain
4244

‎lib/manavault_web/endpoint.ex‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ defmodule ManavaultWeb.Endpoint do
44
# The session will be stored in the cookie and signed,
55
# this means its contents can be read but not tampered with.
66
# Set :encryption_salt if you would also like to encrypt it.
7+
@session_max_age_seconds 60 * 60 * 24 * 180
78
@session_options [
89
store: :cookie,
910
key: "_manavault_key",
1011
signing_salt: "HGc1xdq0",
11-
same_site: "Lax"
12+
same_site: "Lax",
13+
max_age: @session_max_age_seconds
1214
]
1315

1416
@fresh_asset_cache_control "no-cache, no-store, must-revalidate"

‎test/manavault_web/controllers/auth_controller_test.exs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,15 @@ defmodule ManavaultWeb.AuthControllerTest do
9292
assert html_response(conn, 200) =~ ~s(id="manavault-root")
9393
end
9494

95+
test "login sets a persistent session cookie", %{conn: conn} do
96+
configure_password("secret")
97+
98+
conn = post(conn, "/login", %{"password" => "secret", "return_to" => "/collection"})
99+
100+
assert [cookie] = get_resp_header(conn, "set-cookie")
101+
assert cookie =~ "max-age=15552000"
102+
end
103+
95104
test "login rejects an incorrect password", %{conn: conn} do
96105
configure_password("secret")
97106

0 commit comments

Comments
 (0)