Complete Phase 5B pairing flow
Add agent pairing token lifecycle, APK download serving, MemoriC Google sign-in and pairing UX polish, and update implementation plans for Phase 5C.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ android/assets/icon.png
|
|||||||
# Test artifacts
|
# Test artifacts
|
||||||
test.jpg
|
test.jpg
|
||||||
fetched.jpg
|
fetched.jpg
|
||||||
|
*.hprof
|
||||||
|
|
||||||
# Agent runtime artifacts
|
# Agent runtime artifacts
|
||||||
agent/*.log
|
agent/*.log
|
||||||
|
|||||||
6
.gitmodules
vendored
6
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
|||||||
[submodule "poc/memory"]
|
[submodule "memory"]
|
||||||
path = poc/memory
|
path = memory
|
||||||
url = https://git.clouderized.com/davidtio/memory
|
url = https://davidtio@git.clouderized.com/davidtio/memory
|
||||||
|
|||||||
@@ -130,6 +130,58 @@ Status: ✅ Completed (2026-05-21)
|
|||||||
- Cross-user access is blocked on all covered endpoints.
|
- Cross-user access is blocked on all covered endpoints.
|
||||||
- Existing single-user records are migrated to one seeded owner account.
|
- Existing single-user records are migrated to one seeded owner account.
|
||||||
|
|
||||||
|
## Phase 5B: Pairing Token Lifecycle
|
||||||
|
|
||||||
|
Status: ✅ Completed (2026-05-21)
|
||||||
|
|
||||||
|
- Make the agent self-bootstrapping for LAN auth:
|
||||||
|
- Generate a high-entropy pairing token on first run when none is configured.
|
||||||
|
- Persist agent config with owner-only file permissions because it contains secrets.
|
||||||
|
- Add pairing lifecycle API on the agent:
|
||||||
|
- `GET /api/pairing/status`
|
||||||
|
- `GET /api/pairing/reveal` (localhost only)
|
||||||
|
- `POST /api/pairing/rotate` (requires current pairing token)
|
||||||
|
- Add broker token registration:
|
||||||
|
- `POST /agents/pairing-token` stores only a hash and binds the agent token to the authenticated user.
|
||||||
|
- Extend agent status/UI with pairing readiness and token rotation.
|
||||||
|
- App "Save LAN Settings" registers the pairing token with the broker and saves it for LAN direct paths.
|
||||||
|
- Verified on real LAN with broker-connected agent endpoint `192.168.1.85:5051`.
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
|
||||||
|
- A fresh agent install has a usable pairing token without manual config editing.
|
||||||
|
- The token is not exposed to other LAN devices through unauthenticated APIs.
|
||||||
|
- Token rotation invalidates the old LAN token and returns the replacement once.
|
||||||
|
- Mutable LAN endpoints continue to require the current pairing token.
|
||||||
|
- Broker websocket auth accepts tokens registered by the signed-in owner.
|
||||||
|
- Broker and agent both report connected after app pairing.
|
||||||
|
|
||||||
|
## Phase 5C: Pairing UX + Account Polish
|
||||||
|
|
||||||
|
Status: 🚧 In Progress
|
||||||
|
|
||||||
|
- Replace generic auth/setup controls with persistent account and agent state.
|
||||||
|
- App should show:
|
||||||
|
- signed-in Google user identity
|
||||||
|
- sign-out action
|
||||||
|
- paired/not-paired status
|
||||||
|
- connected/disconnected agent state
|
||||||
|
- current LAN endpoint when advertised by broker
|
||||||
|
- Disable "Save LAN Settings" when pairing token is already saved and unchanged.
|
||||||
|
- Add broker metadata APIs:
|
||||||
|
- `GET /me`
|
||||||
|
- `GET /agents`
|
||||||
|
- Prepare for QR/deep-link pairing:
|
||||||
|
- Manual token entry remains available for test/debug.
|
||||||
|
- Production pairing should restore high-entropy tokens and avoid typing.
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
|
||||||
|
- User can understand account and agent pairing state without re-running setup.
|
||||||
|
- Pairing controls are state-aware and disabled when already saved.
|
||||||
|
- User can sign out.
|
||||||
|
- App can fetch current user and paired-agent metadata from broker.
|
||||||
|
|
||||||
## Public Interfaces / Config Additions
|
## Public Interfaces / Config Additions
|
||||||
|
|
||||||
- Config fields:
|
- Config fields:
|
||||||
@@ -141,6 +193,12 @@ Status: ✅ Completed (2026-05-21)
|
|||||||
- `pairing_token` (or token issuer reference)
|
- `pairing_token` (or token issuer reference)
|
||||||
- New/extended APIs:
|
- New/extended APIs:
|
||||||
- `GET /api/status`
|
- `GET /api/status`
|
||||||
|
- `GET /api/pairing/status`
|
||||||
|
- `GET /api/pairing/reveal`
|
||||||
|
- `POST /api/pairing/rotate`
|
||||||
|
- `POST /agents/pairing-token`
|
||||||
|
- `GET /me`
|
||||||
|
- `GET /agents`
|
||||||
- `POST /api/storage/validate`
|
- `POST /api/storage/validate`
|
||||||
- `POST /api/storage/change`
|
- `POST /api/storage/change`
|
||||||
- `GET /api/storage/migration`
|
- `GET /api/storage/migration`
|
||||||
|
|||||||
55
PLAN.md
55
PLAN.md
@@ -13,6 +13,8 @@ that works end-to-end, not a half-finished layer.
|
|||||||
| 1 | Android app — pick and upload photos | ✅ Done |
|
| 1 | Android app — pick and upload photos | ✅ Done |
|
||||||
| 2 | Agent UI v1 — status + storage settings (LAN API/UI) | ✅ Done (2026-05-18) |
|
| 2 | Agent UI v1 — status + storage settings (LAN API/UI) | ✅ Done (2026-05-18) |
|
||||||
| 5A | Multi-user foundation (Google auth + tenant boundaries) | ✅ Done (2026-05-21) |
|
| 5A | Multi-user foundation (Google auth + tenant boundaries) | ✅ Done (2026-05-21) |
|
||||||
|
| 5B | Pairing token lifecycle | ✅ Done (2026-05-21) |
|
||||||
|
| 5C | Pairing UX + account polish | 🚧 In Progress |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,6 +77,55 @@ tenant isolation by authenticated user identity.
|
|||||||
- Cross-user access is blocked on all covered endpoints.
|
- Cross-user access is blocked on all covered endpoints.
|
||||||
- Existing single-user records are migrated to one seeded owner account.
|
- Existing single-user records are migrated to one seeded owner account.
|
||||||
|
|
||||||
|
## Phase 5B — Pairing Token Lifecycle ✅
|
||||||
|
|
||||||
|
**Goal:** Make agent pairing operable without hand-editing config while keeping
|
||||||
|
the LAN token from leaking through unauthenticated network APIs.
|
||||||
|
|
||||||
|
**Done:**
|
||||||
|
- Agent generates and persists a high-entropy pairing token on first run.
|
||||||
|
- Agent config containing secrets is written with owner-only permissions.
|
||||||
|
- Pairing status, localhost-only reveal, and authenticated rotation endpoints added.
|
||||||
|
- Broker accepts user-registered pairing tokens and stores only token hashes.
|
||||||
|
- App save flow registers the pairing token with the broker and keeps using it for LAN direct paths.
|
||||||
|
- Agent UI shows pairing readiness and supports local token reveal/rotation.
|
||||||
|
- Verified on real LAN: Android app registered token, broker accepted agent websocket, and agent published `192.168.1.85:5051`.
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
|
||||||
|
- Fresh installs have a usable pairing token.
|
||||||
|
- LAN clients cannot reveal the token without already knowing it.
|
||||||
|
- Rotating the token invalidates the old phone-side LAN token.
|
||||||
|
- Registered pairing tokens bind agent websocket auth to the signed-in user.
|
||||||
|
- Verified connected state: `broker_connected=true` and broker `/` reported `agent_connected=true`.
|
||||||
|
|
||||||
|
## Phase 5C — Pairing UX + Account Polish 🚧
|
||||||
|
|
||||||
|
**Goal:** Turn the working Phase 5B pairing mechanics into a user-facing account
|
||||||
|
and device setup flow that is clear, stateful, and does not require repeated
|
||||||
|
setup actions.
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- Show signed-in Google identity in the app instead of a generic session label.
|
||||||
|
- Hide sign-in actions after the user is signed in and provide sign-out.
|
||||||
|
- Show paired agent state in the app:
|
||||||
|
- paired or not paired
|
||||||
|
- connected or disconnected
|
||||||
|
- LAN endpoint when available
|
||||||
|
- Disable pairing save action when the saved token has not changed.
|
||||||
|
- Add broker metadata endpoints for current user and paired agents.
|
||||||
|
- Prepare the next pairing UX step:
|
||||||
|
- agent UI presents pairing material
|
||||||
|
- app later scans QR/deep-link instead of manual token entry
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
|
||||||
|
- A signed-in user can see which account is active.
|
||||||
|
- A paired user can see whether the agent is connected and which LAN endpoint is being advertised.
|
||||||
|
- Pairing controls do not invite repeated saves when nothing changed.
|
||||||
|
- User can sign out and return to an unsigned state.
|
||||||
|
- Broker can report current user and agent metadata for app UI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## TODO (Post-Phase 2 Backlog)
|
## TODO (Post-Phase 2 Backlog)
|
||||||
@@ -106,7 +157,9 @@ pick what will become the next implementation phase(s).
|
|||||||
|
|
||||||
### Multi-user foundation
|
### Multi-user foundation
|
||||||
- [x] Phase 5A scoped in `## Phase 5A — Multi-user Foundation (Google Auth + Ownership)` (completed 2026-05-21)
|
- [x] Phase 5A scoped in `## Phase 5A — Multi-user Foundation (Google Auth + Ownership)` (completed 2026-05-21)
|
||||||
- [ ] Remaining auth/pairing lifecycle design follows Phase 5B (to be scoped next)
|
- [x] Phase 5B pairing token lifecycle completed (2026-05-21)
|
||||||
|
- [ ] Phase 5C account and pairing UX polish
|
||||||
|
- [ ] Broker-mediated QR/deep-link pairing flow
|
||||||
|
|
||||||
### Android backup and release
|
### Android backup and release
|
||||||
- [ ] WorkManager job — runs on WiFi, respects battery saver
|
- [ ] WorkManager job — runs on WiFi, respects battery saver
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ Example config:
|
|||||||
"bind_host": "0.0.0.0",
|
"bind_host": "0.0.0.0",
|
||||||
"bind_port": 5051,
|
"bind_port": 5051,
|
||||||
"allowed_subnets": ["192.168.0.0/16", "10.0.0.0/8", "127.0.0.0/8"],
|
"allowed_subnets": ["192.168.0.0/16", "10.0.0.0/8", "127.0.0.0/8"],
|
||||||
|
"auth_mode": "pairing_token",
|
||||||
"pairing_token": "change-me"
|
"pairing_token": "change-me"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -112,4 +113,10 @@ Phase 1 endpoints:
|
|||||||
|
|
||||||
Auth for mutable endpoints:
|
Auth for mutable endpoints:
|
||||||
- `Authorization: Bearer <pairing_token>` or `X-Agent-Token: <pairing_token>`
|
- `Authorization: Bearer <pairing_token>` or `X-Agent-Token: <pairing_token>`
|
||||||
- If no `pairing_token` is configured, mutable endpoints are disabled.
|
- If no `pairing_token` is configured, the agent generates and persists one on startup.
|
||||||
|
|
||||||
|
Pairing lifecycle endpoints:
|
||||||
|
- `GET /api/pairing/status` (subnet-restricted)
|
||||||
|
- `GET /api/pairing/reveal` (subnet-restricted, localhost only)
|
||||||
|
- `POST /api/pairing/rotate` (subnet-restricted + current token auth)
|
||||||
|
- `POST /agents/pairing-token` on broker (app JWT required; stores only a token hash)
|
||||||
|
|||||||
391
agent/main.go
391
agent/main.go
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -41,6 +42,7 @@ type Config struct {
|
|||||||
BindHost string `json:"bind_host"`
|
BindHost string `json:"bind_host"`
|
||||||
BindPort int `json:"bind_port"`
|
BindPort int `json:"bind_port"`
|
||||||
AllowedSubnets []string `json:"allowed_subnets"`
|
AllowedSubnets []string `json:"allowed_subnets"`
|
||||||
|
AuthMode string `json:"auth_mode"`
|
||||||
PairingToken string `json:"pairing_token"`
|
PairingToken string `json:"pairing_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +78,8 @@ type StatusResponse struct {
|
|||||||
StorageDir string `json:"storage_dir"`
|
StorageDir string `json:"storage_dir"`
|
||||||
BindAddr string `json:"bind_addr"`
|
BindAddr string `json:"bind_addr"`
|
||||||
AllowedSubnets []string `json:"allowed_subnets"`
|
AllowedSubnets []string `json:"allowed_subnets"`
|
||||||
|
AuthMode string `json:"auth_mode"`
|
||||||
|
PairingReady bool `json:"pairing_ready"`
|
||||||
UploadsHandled int64 `json:"uploads_handled"`
|
UploadsHandled int64 `json:"uploads_handled"`
|
||||||
FetchesHandled int64 `json:"fetches_handled"`
|
FetchesHandled int64 `json:"fetches_handled"`
|
||||||
LastError string `json:"last_error,omitempty"`
|
LastError string `json:"last_error,omitempty"`
|
||||||
@@ -108,6 +112,18 @@ type MigrationStatus struct {
|
|||||||
CompletedAt string `json:"completed_at,omitempty"`
|
CompletedAt string `json:"completed_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PairingStatusResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
AuthMode string `json:"auth_mode"`
|
||||||
|
PairingReady bool `json:"pairing_ready"`
|
||||||
|
CanReveal bool `json:"can_reveal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PairingTokenResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
func defaultStorageDir() string {
|
func defaultStorageDir() string {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil || home == "" {
|
if err != nil || home == "" {
|
||||||
@@ -130,6 +146,7 @@ func loadConfig() Config {
|
|||||||
BindHost: "0.0.0.0",
|
BindHost: "0.0.0.0",
|
||||||
BindPort: 5051,
|
BindPort: 5051,
|
||||||
AllowedSubnets: append([]string(nil), defaultAllowedSubnets...),
|
AllowedSubnets: append([]string(nil), defaultAllowedSubnets...),
|
||||||
|
AuthMode: "pairing_token",
|
||||||
}
|
}
|
||||||
|
|
||||||
if env := strings.TrimSpace(os.Getenv("STORAGE_DIR")); env != "" {
|
if env := strings.TrimSpace(os.Getenv("STORAGE_DIR")); env != "" {
|
||||||
@@ -182,12 +199,38 @@ func loadConfig() Config {
|
|||||||
if len(fileCfg.AllowedSubnets) > 0 {
|
if len(fileCfg.AllowedSubnets) > 0 {
|
||||||
cfg.AllowedSubnets = fileCfg.AllowedSubnets
|
cfg.AllowedSubnets = fileCfg.AllowedSubnets
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(fileCfg.AuthMode) != "" {
|
||||||
|
cfg.AuthMode = strings.TrimSpace(fileCfg.AuthMode)
|
||||||
|
}
|
||||||
if strings.TrimSpace(fileCfg.PairingToken) != "" {
|
if strings.TrimSpace(fileCfg.PairingToken) != "" {
|
||||||
cfg.PairingToken = strings.TrimSpace(fileCfg.PairingToken)
|
cfg.PairingToken = strings.TrimSpace(fileCfg.PairingToken)
|
||||||
}
|
}
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generatePairingToken() (string, error) {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistInitialConfig(cfg Config) error {
|
||||||
|
return writeConfigFile(configPath(), cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeConfigFile(path string, cfg Config) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, append(b, '\n'), 0600)
|
||||||
|
}
|
||||||
|
|
||||||
func parseAllowedSubnets(values []string) ([]netip.Prefix, error) {
|
func parseAllowedSubnets(values []string) ([]netip.Prefix, error) {
|
||||||
prefixes := make([]netip.Prefix, 0, len(values))
|
prefixes := make([]netip.Prefix, 0, len(values))
|
||||||
for _, raw := range values {
|
for _, raw := range values {
|
||||||
@@ -239,6 +282,8 @@ func (s *AgentState) status(allowed []string) StatusResponse {
|
|||||||
StorageDir: s.storageDir,
|
StorageDir: s.storageDir,
|
||||||
BindAddr: s.bindAddr,
|
BindAddr: s.bindAddr,
|
||||||
AllowedSubnets: append([]string(nil), allowed...),
|
AllowedSubnets: append([]string(nil), allowed...),
|
||||||
|
AuthMode: s.config.AuthMode,
|
||||||
|
PairingReady: strings.TrimSpace(s.token) != "",
|
||||||
UploadsHandled: s.uploadsHandled,
|
UploadsHandled: s.uploadsHandled,
|
||||||
FetchesHandled: s.fetchesHandled,
|
FetchesHandled: s.fetchesHandled,
|
||||||
LastError: s.lastError,
|
LastError: s.lastError,
|
||||||
@@ -272,14 +317,31 @@ func (s *AgentState) persistConfig() error {
|
|||||||
path := s.configPath
|
path := s.configPath
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
return writeConfigFile(path, cfg)
|
||||||
return err
|
}
|
||||||
|
|
||||||
|
func (s *AgentState) persistConfigWithPairingToken(token string) error {
|
||||||
|
s.mu.RLock()
|
||||||
|
cfg := s.config
|
||||||
|
cfg.StorageDir = s.storageDir
|
||||||
|
cfg.PairingToken = token
|
||||||
|
if strings.TrimSpace(cfg.AuthMode) == "" {
|
||||||
|
cfg.AuthMode = "pairing_token"
|
||||||
}
|
}
|
||||||
b, err := json.MarshalIndent(cfg, "", " ")
|
path := s.configPath
|
||||||
if err != nil {
|
s.mu.RUnlock()
|
||||||
return err
|
|
||||||
|
return writeConfigFile(path, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentState) setPairingToken(token string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.token = token
|
||||||
|
s.config.PairingToken = token
|
||||||
|
if strings.TrimSpace(s.config.AuthMode) == "" {
|
||||||
|
s.config.AuthMode = "pairing_token"
|
||||||
}
|
}
|
||||||
return os.WriteFile(path, append(b, '\n'), 0644)
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateStoragePath(raw string) StorageValidation {
|
func validateStoragePath(raw string) StorageValidation {
|
||||||
@@ -538,6 +600,23 @@ func (s *AgentState) clientAllowed(r *http.Request) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requestIP(r *http.Request) (netip.Addr, bool) {
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
host = r.RemoteAddr
|
||||||
|
}
|
||||||
|
ip, err := netip.ParseAddr(strings.Trim(host, "[]"))
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}, false
|
||||||
|
}
|
||||||
|
return ip, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentState) clientLoopback(r *http.Request) bool {
|
||||||
|
ip, ok := requestIP(r)
|
||||||
|
return ok && ip.IsLoopback()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AgentState) requireSubnet(next http.HandlerFunc) http.HandlerFunc {
|
func (s *AgentState) requireSubnet(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.clientAllowed(r) {
|
if !s.clientAllowed(r) {
|
||||||
@@ -568,6 +647,17 @@ func (s *AgentState) requireMutableAuth(next http.HandlerFunc) http.HandlerFunc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AgentState) pairingStatus(r *http.Request) PairingStatusResponse {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return PairingStatusResponse{
|
||||||
|
Status: "ok",
|
||||||
|
AuthMode: s.config.AuthMode,
|
||||||
|
PairingReady: strings.TrimSpace(s.token) != "",
|
||||||
|
CanReveal: s.clientLoopback(r),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sanitizeFilename(name string) (string, error) {
|
func sanitizeFilename(name string) (string, error) {
|
||||||
cleaned := strings.TrimSpace(name)
|
cleaned := strings.TrimSpace(name)
|
||||||
if cleaned == "" {
|
if cleaned == "" {
|
||||||
@@ -596,6 +686,24 @@ func (s *AgentState) saveUpload(filename string, data []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func candidateAPKPaths() []string {
|
||||||
|
paths := []string{"memoric.apk"}
|
||||||
|
if exe, err := os.Executable(); err == nil && exe != "" {
|
||||||
|
paths = append(paths, filepath.Join(filepath.Dir(exe), "memoric.apk"))
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func findAPKPath() (string, bool) {
|
||||||
|
for _, path := range candidateAPKPaths() {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err == nil && !info.IsDir() {
|
||||||
|
return path, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AgentState) startHTTPServer(allowedSubnets []string) {
|
func (s *AgentState) startHTTPServer(allowedSubnets []string) {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
@@ -607,6 +715,53 @@ func (s *AgentState) startHTTPServer(allowedSubnets []string) {
|
|||||||
s.writeJSON(w, http.StatusOK, s.status(allowedSubnets))
|
s.writeJSON(w, http.StatusOK, s.status(allowedSubnets))
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/pairing/status", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeJSON(w, http.StatusOK, s.pairingStatus(r))
|
||||||
|
}))
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/pairing/reveal", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.clientLoopback(r) {
|
||||||
|
s.writeJSON(w, http.StatusForbidden, map[string]string{"error": "pairing token can only be revealed from localhost"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
token := s.token
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if strings.TrimSpace(token) == "" {
|
||||||
|
s.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "pairing token is not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeJSON(w, http.StatusOK, PairingTokenResponse{Status: "ok", Token: token})
|
||||||
|
}))
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/pairing/rotate", s.requireSubnet(s.requireMutableAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := generatePairingToken()
|
||||||
|
if err != nil {
|
||||||
|
s.setError(err)
|
||||||
|
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to generate pairing token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.persistConfigWithPairingToken(token); err != nil {
|
||||||
|
s.setError(err)
|
||||||
|
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to persist pairing token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.setPairingToken(token)
|
||||||
|
s.writeJSON(w, http.StatusOK, PairingTokenResponse{Status: "ok", Token: token})
|
||||||
|
})))
|
||||||
|
|
||||||
mux.HandleFunc("/api/storage/validate", s.requireSubnet(s.requireMutableAuth(func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/storage/validate", s.requireSubnet(s.requireMutableAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||||
@@ -762,6 +917,21 @@ func (s *AgentState) startHTTPServer(allowedSubnets []string) {
|
|||||||
_, _ = w.Write(b)
|
_, _ = w.Write(b)
|
||||||
})))
|
})))
|
||||||
|
|
||||||
|
mux.HandleFunc("/download/memoric.apk", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path, ok := findAPKPath()
|
||||||
|
if !ok {
|
||||||
|
s.writeJSON(w, http.StatusNotFound, map[string]string{"error": "memoric.apk not installed on agent"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="memoric.apk"`)
|
||||||
|
http.ServeFile(w, r, path)
|
||||||
|
}))
|
||||||
|
|
||||||
mux.HandleFunc("/", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
@@ -786,11 +956,21 @@ func (s *AgentState) startHTTPServer(allowedSubnets []string) {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>MemoriC Agent</h1>
|
<h1>MemoriC Agent</h1>
|
||||||
|
<div class="box">
|
||||||
|
<h3>Android App</h3>
|
||||||
|
<div class="row"><a href="/download/memoric.apk">Download MemoriC APK</a></div>
|
||||||
|
</div>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<h3>Status</h3>
|
<h3>Status</h3>
|
||||||
<div id="status">Loading...</div>
|
<div id="status">Loading...</div>
|
||||||
<div class="row"><button id="refresh">Refresh</button></div>
|
<div class="row"><button id="refresh">Refresh</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="box">
|
||||||
|
<h3>Pairing</h3>
|
||||||
|
<div id="pairing">Loading...</div>
|
||||||
|
<div class="row"><button id="show-token">Show Token</button> <button id="rotate-token">Rotate Token</button></div>
|
||||||
|
<pre id="pairing-result"></pre>
|
||||||
|
</div>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<h3>Storage Settings</h3>
|
<h3>Storage Settings</h3>
|
||||||
<div class="row"><label>Storage Directory</label></div>
|
<div class="row"><label>Storage Directory</label></div>
|
||||||
@@ -805,9 +985,19 @@ async function loadStatus() {
|
|||||||
document.getElementById('status').innerText =
|
document.getElementById('status').innerText =
|
||||||
'Broker: ' + (data.broker_connected ? 'connected' : 'disconnected') +
|
'Broker: ' + (data.broker_connected ? 'connected' : 'disconnected') +
|
||||||
'\nBind: ' + data.bind_addr +
|
'\nBind: ' + data.bind_addr +
|
||||||
|
'\nAuth: ' + data.auth_mode + ' (' + (data.pairing_ready ? 'paired' : 'not ready') + ')' +
|
||||||
'\nStorage: ' + data.storage_dir +
|
'\nStorage: ' + data.storage_dir +
|
||||||
'\nLast Error: ' + (data.last_error || '-');
|
'\nLast Error: ' + (data.last_error || '-');
|
||||||
document.getElementById('path').value = data.storage_dir || '';
|
document.getElementById('path').value = data.storage_dir || '';
|
||||||
|
await loadPairing();
|
||||||
|
}
|
||||||
|
async function loadPairing() {
|
||||||
|
const res = await fetch('/api/pairing/status');
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('pairing').innerText =
|
||||||
|
'Mode: ' + data.auth_mode +
|
||||||
|
'\nReady: ' + (data.pairing_ready ? 'yes' : 'no') +
|
||||||
|
'\nLocal token reveal: ' + (data.can_reveal ? 'available' : 'localhost only');
|
||||||
}
|
}
|
||||||
async function postJSON(url, body) {
|
async function postJSON(url, body) {
|
||||||
const token = prompt('Enter pairing token');
|
const token = prompt('Enter pairing token');
|
||||||
@@ -820,6 +1010,21 @@ async function postJSON(url, body) {
|
|||||||
document.getElementById('result').innerText = JSON.stringify(data, null, 2);
|
document.getElementById('result').innerText = JSON.stringify(data, null, 2);
|
||||||
await loadStatus();
|
await loadStatus();
|
||||||
}
|
}
|
||||||
|
async function showToken() {
|
||||||
|
const res = await fetch('/api/pairing/reveal');
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('pairing-result').innerText = JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
async function rotateToken() {
|
||||||
|
const token = prompt('Enter current pairing token');
|
||||||
|
const res = await fetch('/api/pairing/rotate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Authorization': 'Bearer ' + (token || '')}
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('pairing-result').innerText = JSON.stringify(data, null, 2);
|
||||||
|
await loadStatus();
|
||||||
|
}
|
||||||
async function loadMigration() {
|
async function loadMigration() {
|
||||||
const res = await fetch('/api/storage/migration');
|
const res = await fetch('/api/storage/migration');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -829,6 +1034,8 @@ async function loadMigration() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.getElementById('refresh').onclick = loadStatus;
|
document.getElementById('refresh').onclick = loadStatus;
|
||||||
|
document.getElementById('show-token').onclick = showToken;
|
||||||
|
document.getElementById('rotate-token').onclick = rotateToken;
|
||||||
document.getElementById('validate').onclick = () => postJSON('/api/storage/validate', {path: document.getElementById('path').value});
|
document.getElementById('validate').onclick = () => postJSON('/api/storage/validate', {path: document.getElementById('path').value});
|
||||||
document.getElementById('change').onclick = () => postJSON('/api/storage/change', {path: document.getElementById('path').value});
|
document.getElementById('change').onclick = () => postJSON('/api/storage/change', {path: document.getElementById('path').value});
|
||||||
loadStatus();
|
loadStatus();
|
||||||
@@ -889,8 +1096,106 @@ func getLANEndpoints(bindHost string, bindPort int) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AgentState) runBrokerSession(cfg Config) {
|
||||||
|
wsURL := brokerURL
|
||||||
|
if s.token != "" {
|
||||||
|
wsURL = brokerURL + "?token=" + url.QueryEscape(s.token)
|
||||||
|
}
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
s.setConnected(false)
|
||||||
|
s.setError(err)
|
||||||
|
log.Printf("broker connect failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
s.setConnected(true)
|
||||||
|
log.Println("connected to broker at", wsURL)
|
||||||
|
hello, _ := json.Marshal(Message{
|
||||||
|
Type: "agent_hello",
|
||||||
|
LANEndpoints: getLANEndpoints(cfg.BindHost, cfg.BindPort),
|
||||||
|
BindPort: cfg.BindPort,
|
||||||
|
})
|
||||||
|
if err := conn.WriteMessage(websocket.TextMessage, hello); err != nil {
|
||||||
|
s.setConnected(false)
|
||||||
|
s.setError(err)
|
||||||
|
log.Printf("failed sending agent_hello: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, raw, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
s.setConnected(false)
|
||||||
|
s.setError(err)
|
||||||
|
log.Printf("broker read failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg Message
|
||||||
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
|
s.setError(err)
|
||||||
|
log.Println("bad message:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg.Type {
|
||||||
|
case "upload":
|
||||||
|
data, err := base64.StdEncoding.DecodeString(msg.Data)
|
||||||
|
if err != nil {
|
||||||
|
s.setError(err)
|
||||||
|
log.Println("decode error:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.saveUpload(msg.Filename, data); err != nil {
|
||||||
|
s.setError(err)
|
||||||
|
log.Println("write error:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("saved %s (%d bytes)", msg.Filename, len(data))
|
||||||
|
|
||||||
|
case "fetch":
|
||||||
|
path := filepath.Join(s.getStorageDir(), msg.Filename)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("fetch %s: not found", msg.Filename)
|
||||||
|
resp, _ := json.Marshal(Message{
|
||||||
|
Type: "fetch_response",
|
||||||
|
RequestID: msg.RequestID,
|
||||||
|
Error: "not found",
|
||||||
|
})
|
||||||
|
conn.WriteMessage(websocket.TextMessage, resp)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp, _ := json.Marshal(Message{
|
||||||
|
Type: "fetch_response",
|
||||||
|
RequestID: msg.RequestID,
|
||||||
|
Data: base64.StdEncoding.EncodeToString(data),
|
||||||
|
})
|
||||||
|
conn.WriteMessage(websocket.TextMessage, resp)
|
||||||
|
s.incFetch()
|
||||||
|
log.Printf("served %s (%d bytes)", msg.Filename, len(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := loadConfig()
|
cfg := loadConfig()
|
||||||
|
if strings.TrimSpace(cfg.AuthMode) == "" {
|
||||||
|
cfg.AuthMode = "pairing_token"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.PairingToken) == "" {
|
||||||
|
token, err := generatePairingToken()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("generate pairing token: %v", err)
|
||||||
|
}
|
||||||
|
cfg.PairingToken = token
|
||||||
|
if err := persistInitialConfig(cfg); err != nil {
|
||||||
|
log.Fatalf("persist generated pairing token: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("generated pairing token and saved config at %s", configPath())
|
||||||
|
}
|
||||||
prefixes, err := parseAllowedSubnets(cfg.AllowedSubnets)
|
prefixes, err := parseAllowedSubnets(cfg.AllowedSubnets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("subnet config error: %v", err)
|
log.Fatalf("subnet config error: %v", err)
|
||||||
@@ -918,78 +1223,8 @@ func main() {
|
|||||||
|
|
||||||
go state.startHTTPServer(cfg.AllowedSubnets)
|
go state.startHTTPServer(cfg.AllowedSubnets)
|
||||||
|
|
||||||
wsURL := brokerURL
|
|
||||||
if state.token != "" {
|
|
||||||
wsURL = brokerURL + "?token=" + url.QueryEscape(state.token)
|
|
||||||
}
|
|
||||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("connect:", err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
state.setConnected(true)
|
|
||||||
log.Println("connected to broker at", wsURL)
|
|
||||||
hello, _ := json.Marshal(Message{
|
|
||||||
Type: "agent_hello",
|
|
||||||
LANEndpoints: getLANEndpoints(cfg.BindHost, cfg.BindPort),
|
|
||||||
BindPort: cfg.BindPort,
|
|
||||||
})
|
|
||||||
if err := conn.WriteMessage(websocket.TextMessage, hello); err != nil {
|
|
||||||
state.setError(err)
|
|
||||||
log.Printf("failed sending agent_hello: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
_, raw, err := conn.ReadMessage()
|
state.runBrokerSession(cfg)
|
||||||
if err != nil {
|
time.Sleep(5 * time.Second)
|
||||||
state.setConnected(false)
|
|
||||||
state.setError(err)
|
|
||||||
log.Fatal("read:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var msg Message
|
|
||||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
|
||||||
state.setError(err)
|
|
||||||
log.Println("bad message:", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch msg.Type {
|
|
||||||
case "upload":
|
|
||||||
data, err := base64.StdEncoding.DecodeString(msg.Data)
|
|
||||||
if err != nil {
|
|
||||||
state.setError(err)
|
|
||||||
log.Println("decode error:", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := state.saveUpload(msg.Filename, data); err != nil {
|
|
||||||
state.setError(err)
|
|
||||||
log.Println("write error:", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Printf("saved %s (%d bytes)", msg.Filename, len(data))
|
|
||||||
|
|
||||||
case "fetch":
|
|
||||||
path := filepath.Join(state.getStorageDir(), msg.Filename)
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("fetch %s: not found", msg.Filename)
|
|
||||||
resp, _ := json.Marshal(Message{
|
|
||||||
Type: "fetch_response",
|
|
||||||
RequestID: msg.RequestID,
|
|
||||||
Error: "not found",
|
|
||||||
})
|
|
||||||
conn.WriteMessage(websocket.TextMessage, resp)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resp, _ := json.Marshal(Message{
|
|
||||||
Type: "fetch_response",
|
|
||||||
RequestID: msg.RequestID,
|
|
||||||
Data: base64.StdEncoding.EncodeToString(data),
|
|
||||||
})
|
|
||||||
conn.WriteMessage(websocket.TextMessage, resp)
|
|
||||||
state.incFetch()
|
|
||||||
log.Printf("served %s (%d bytes)", msg.Filename, len(data))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=false
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_sign_in/google_sign_in.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
@@ -10,6 +11,7 @@ import 'package:path/path.dart' as p;
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
const brokerUrl = 'https://memory.clouderized.com';
|
const brokerUrl = 'https://memory.clouderized.com';
|
||||||
|
const googleServerClientId = String.fromEnvironment('GOOGLE_SERVER_CLIENT_ID');
|
||||||
|
|
||||||
void main() => runApp(const MemoryApp());
|
void main() => runApp(const MemoryApp());
|
||||||
|
|
||||||
@@ -64,6 +66,13 @@ class DirectUploadConfig {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AuthResult {
|
||||||
|
final bool ok;
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const AuthResult(this.ok, this.message);
|
||||||
|
}
|
||||||
|
|
||||||
class UploadPage extends StatefulWidget {
|
class UploadPage extends StatefulWidget {
|
||||||
const UploadPage({super.key});
|
const UploadPage({super.key});
|
||||||
|
|
||||||
@@ -80,16 +89,23 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
bool _agentConnected = false;
|
bool _agentConnected = false;
|
||||||
Timer? _statusTimer;
|
Timer? _statusTimer;
|
||||||
DirectUploadConfig _directConfig = DirectUploadConfig.defaults;
|
DirectUploadConfig _directConfig = DirectUploadConfig.defaults;
|
||||||
|
final GoogleSignIn _googleSignIn = GoogleSignIn(
|
||||||
|
scopes: ['email'],
|
||||||
|
serverClientId: googleServerClientId == '' ? null : googleServerClientId,
|
||||||
|
);
|
||||||
late final TextEditingController _tokenController;
|
late final TextEditingController _tokenController;
|
||||||
late final TextEditingController _googleIdTokenController;
|
|
||||||
String _accessToken = '';
|
String _accessToken = '';
|
||||||
String _refreshToken = '';
|
String _refreshToken = '';
|
||||||
|
String _userEmail = '';
|
||||||
|
String _userDisplayName = '';
|
||||||
|
String _userPictureUrl = '';
|
||||||
|
String _agentLanEndpoint = '';
|
||||||
|
bool _editingPairing = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tokenController = TextEditingController();
|
_tokenController = TextEditingController();
|
||||||
_googleIdTokenController = TextEditingController();
|
|
||||||
_loadBrokerSession();
|
_loadBrokerSession();
|
||||||
_loadDirectUploadConfig();
|
_loadDirectUploadConfig();
|
||||||
_loadGallery();
|
_loadGallery();
|
||||||
@@ -103,7 +119,6 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_statusTimer?.cancel();
|
_statusTimer?.cancel();
|
||||||
_tokenController.dispose();
|
_tokenController.dispose();
|
||||||
_googleIdTokenController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +128,13 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_accessToken = prefs.getString('broker_access_token') ?? '';
|
_accessToken = prefs.getString('broker_access_token') ?? '';
|
||||||
_refreshToken = prefs.getString('broker_refresh_token') ?? '';
|
_refreshToken = prefs.getString('broker_refresh_token') ?? '';
|
||||||
|
_userEmail = prefs.getString('broker_user_email') ?? '';
|
||||||
|
_userDisplayName = prefs.getString('broker_user_display_name') ?? '';
|
||||||
|
_userPictureUrl = prefs.getString('broker_user_picture_url') ?? '';
|
||||||
});
|
});
|
||||||
|
if (_accessToken.isNotEmpty || _refreshToken.isNotEmpty) {
|
||||||
|
unawaited(_fetchCurrentUser());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveBrokerSession({
|
Future<void> _saveBrokerSession({
|
||||||
@@ -130,20 +151,63 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _saveUserProfile(Map<String, dynamic> user) async {
|
||||||
|
final email = (user['email'] ?? '').toString();
|
||||||
|
final displayName = (user['display_name'] ?? '').toString();
|
||||||
|
final pictureUrl = (user['picture_url'] ?? '').toString();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('broker_user_email', email);
|
||||||
|
await prefs.setString('broker_user_display_name', displayName);
|
||||||
|
await prefs.setString('broker_user_picture_url', pictureUrl);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_userEmail = email;
|
||||||
|
_userDisplayName = displayName;
|
||||||
|
_userPictureUrl = pictureUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchCurrentUser() async {
|
||||||
|
try {
|
||||||
|
final token = await _getValidAccessToken();
|
||||||
|
if (token == null) return;
|
||||||
|
final res = await http.get(
|
||||||
|
Uri.parse('$brokerUrl/me'),
|
||||||
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
);
|
||||||
|
if (res.statusCode != 200) return;
|
||||||
|
final body = jsonDecode(res.body);
|
||||||
|
if (body is Map<String, dynamic>) {
|
||||||
|
await _saveUserProfile(body);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Keep cached profile if refresh fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _clearBrokerSession() async {
|
Future<void> _clearBrokerSession() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove('broker_access_token');
|
await prefs.remove('broker_access_token');
|
||||||
await prefs.remove('broker_refresh_token');
|
await prefs.remove('broker_refresh_token');
|
||||||
|
await prefs.remove('broker_user_email');
|
||||||
|
await prefs.remove('broker_user_display_name');
|
||||||
|
await prefs.remove('broker_user_picture_url');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_accessToken = '';
|
_accessToken = '';
|
||||||
_refreshToken = '';
|
_refreshToken = '';
|
||||||
|
_userEmail = '';
|
||||||
|
_userDisplayName = '';
|
||||||
|
_userPictureUrl = '';
|
||||||
|
_agentConnected = false;
|
||||||
|
_agentLanEndpoint = '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _signInWithGoogleIdToken() async {
|
Future<AuthResult> _exchangeGoogleIdToken(String idToken) async {
|
||||||
final idToken = _googleIdTokenController.text.trim();
|
if (idToken.isEmpty) {
|
||||||
if (idToken.isEmpty) return false;
|
return const AuthResult(false, 'Google did not return an ID token.');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final res = await http.post(
|
final res = await http.post(
|
||||||
@@ -151,18 +215,65 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({'id_token': idToken}),
|
body: jsonEncode({'id_token': idToken}),
|
||||||
);
|
);
|
||||||
if (res.statusCode != 200) return false;
|
if (res.statusCode != 200) {
|
||||||
|
var detail = res.body.trim();
|
||||||
|
try {
|
||||||
|
final body = jsonDecode(res.body);
|
||||||
|
if (body is Map && body['error'] != null) {
|
||||||
|
detail = body['error'].toString();
|
||||||
|
} else if (body is Map && body['detail'] != null) {
|
||||||
|
detail = body['detail'].toString();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Keep raw response body.
|
||||||
|
}
|
||||||
|
return AuthResult(
|
||||||
|
false,
|
||||||
|
'Broker rejected Google sign-in (${res.statusCode}): $detail',
|
||||||
|
);
|
||||||
|
}
|
||||||
final body = jsonDecode(res.body);
|
final body = jsonDecode(res.body);
|
||||||
final accessToken = (body['access_token'] ?? '').toString();
|
final accessToken = (body['access_token'] ?? '').toString();
|
||||||
final refreshToken = (body['refresh_token'] ?? '').toString();
|
final refreshToken = (body['refresh_token'] ?? '').toString();
|
||||||
if (accessToken.isEmpty || refreshToken.isEmpty) return false;
|
if (accessToken.isEmpty || refreshToken.isEmpty) {
|
||||||
|
return const AuthResult(
|
||||||
|
false,
|
||||||
|
'Broker response did not include a session.',
|
||||||
|
);
|
||||||
|
}
|
||||||
await _saveBrokerSession(
|
await _saveBrokerSession(
|
||||||
accessToken: accessToken,
|
accessToken: accessToken,
|
||||||
refreshToken: refreshToken,
|
refreshToken: refreshToken,
|
||||||
);
|
);
|
||||||
return true;
|
final user = body['user'];
|
||||||
} catch (_) {
|
if (user is Map<String, dynamic>) {
|
||||||
return false;
|
await _saveUserProfile(user);
|
||||||
|
} else {
|
||||||
|
await _fetchCurrentUser();
|
||||||
|
}
|
||||||
|
return const AuthResult(true, 'Broker sign-in successful.');
|
||||||
|
} catch (e) {
|
||||||
|
return AuthResult(false, 'Sign-in request failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AuthResult> _signInWithGoogle() async {
|
||||||
|
try {
|
||||||
|
final account = await _googleSignIn.signIn();
|
||||||
|
if (account == null) {
|
||||||
|
return const AuthResult(false, 'Google sign-in was cancelled.');
|
||||||
|
}
|
||||||
|
final auth = await account.authentication;
|
||||||
|
final idToken = auth.idToken;
|
||||||
|
if (idToken == null || idToken.isEmpty) {
|
||||||
|
return const AuthResult(
|
||||||
|
false,
|
||||||
|
'Google did not return an ID token. Check Android OAuth client config.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _exchangeGoogleIdToken(idToken);
|
||||||
|
} catch (e) {
|
||||||
|
return AuthResult(false, 'Google sign-in failed: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +303,27 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _signOut() async {
|
||||||
|
final refreshToken = _refreshToken;
|
||||||
|
try {
|
||||||
|
if (refreshToken.isNotEmpty) {
|
||||||
|
await http.post(
|
||||||
|
Uri.parse('$brokerUrl/auth/logout'),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'refresh_token': refreshToken}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Local sign-out should still complete.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _googleSignIn.signOut();
|
||||||
|
} catch (_) {
|
||||||
|
// Google session may already be absent.
|
||||||
|
}
|
||||||
|
await _clearBrokerSession();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadDirectUploadConfig() async {
|
Future<void> _loadDirectUploadConfig() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final cfg = DirectUploadConfig(
|
final cfg = DirectUploadConfig(
|
||||||
@@ -208,6 +340,19 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
|
|
||||||
Future<void> _saveDirectUploadConfig() async {
|
Future<void> _saveDirectUploadConfig() async {
|
||||||
final token = _tokenController.text.trim();
|
final token = _tokenController.text.trim();
|
||||||
|
if (token.isEmpty) return;
|
||||||
|
final registered = await _registerPairingTokenWithBroker(token);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
registered
|
||||||
|
? 'Pairing token registered with broker.'
|
||||||
|
: 'Could not register pairing token with broker.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool('direct_upload_enabled', true);
|
await prefs.setBool('direct_upload_enabled', true);
|
||||||
await prefs.remove('direct_upload_endpoint');
|
await prefs.remove('direct_upload_endpoint');
|
||||||
@@ -219,9 +364,39 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
endpoint: '',
|
endpoint: '',
|
||||||
token: token,
|
token: token,
|
||||||
);
|
);
|
||||||
|
_editingPairing = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get _hasBrokerSession =>
|
||||||
|
_accessToken.isNotEmpty || _refreshToken.isNotEmpty;
|
||||||
|
|
||||||
|
bool get _hasSavedPairingToken => _directConfig.token.trim().isNotEmpty;
|
||||||
|
|
||||||
|
bool get _pairingInputChanged =>
|
||||||
|
_tokenController.text.trim() != _directConfig.token.trim();
|
||||||
|
|
||||||
|
Future<bool> _registerPairingTokenWithBroker(String pairingToken) async {
|
||||||
|
try {
|
||||||
|
final accessToken = await _getValidAccessToken();
|
||||||
|
if (accessToken == null) return false;
|
||||||
|
final res = await http.post(
|
||||||
|
Uri.parse('$brokerUrl/agents/pairing-token'),
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer $accessToken',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'pairing_token': pairingToken,
|
||||||
|
'name': 'Primary Agent',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return res.statusCode == 200;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _pickPhotos() async {
|
Future<void> _pickPhotos() async {
|
||||||
final picked = await _picker.pickMultiImage();
|
final picked = await _picker.pickMultiImage();
|
||||||
if (picked.isEmpty) return;
|
if (picked.isEmpty) return;
|
||||||
@@ -479,7 +654,10 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
final token = await _getValidAccessToken();
|
final token = await _getValidAccessToken();
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _agentConnected = false);
|
setState(() {
|
||||||
|
_agentConnected = false;
|
||||||
|
_agentLanEndpoint = '';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -489,18 +667,37 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
);
|
);
|
||||||
if (res.statusCode != 200) {
|
if (res.statusCode != 200) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _agentConnected = false);
|
setState(() {
|
||||||
|
_agentConnected = false;
|
||||||
|
_agentLanEndpoint = '';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final body = jsonDecode(res.body);
|
final body = jsonDecode(res.body);
|
||||||
final connected = _extractAgentConnected(body);
|
final connected = _extractAgentConnected(body);
|
||||||
|
var endpoint = '';
|
||||||
|
if (body is Map<String, dynamic> && body['lan_endpoints'] is List) {
|
||||||
|
for (final raw in body['lan_endpoints'] as List) {
|
||||||
|
final value = raw.toString().trim();
|
||||||
|
if (value.isNotEmpty) {
|
||||||
|
endpoint = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _agentConnected = connected);
|
setState(() {
|
||||||
|
_agentConnected = connected;
|
||||||
|
_agentLanEndpoint = endpoint;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _agentConnected = false);
|
setState(() {
|
||||||
|
_agentConnected = false;
|
||||||
|
_agentLanEndpoint = '';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,6 +765,7 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
final encoded = Uri.encodeComponent(photo.filename!);
|
final encoded = Uri.encodeComponent(photo.filename!);
|
||||||
directUrl = hints.first.resolve('/api/photo/$encoded').toString();
|
directUrl = hints.first.resolve('/api/photo/$encoded').toString();
|
||||||
}
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
@@ -701,66 +899,148 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDirectUploadSettings() {
|
Widget _buildDirectUploadSettings() {
|
||||||
|
final signedIn = _hasBrokerSession;
|
||||||
|
final paired = _hasSavedPairingToken;
|
||||||
|
final canSavePairing =
|
||||||
|
signedIn &&
|
||||||
|
_tokenController.text.trim().isNotEmpty &&
|
||||||
|
(!paired || _editingPairing) &&
|
||||||
|
_pairingInputChanged;
|
||||||
|
final accountLabel = _userDisplayName.trim().isNotEmpty
|
||||||
|
? _userDisplayName.trim()
|
||||||
|
: _userEmail.trim().isNotEmpty
|
||||||
|
? _userEmail.trim()
|
||||||
|
: 'Signed in with Google';
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
margin: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (signedIn && _userPictureUrl.isNotEmpty)
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 12,
|
||||||
|
backgroundImage: NetworkImage(_userPictureUrl),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Icon(
|
||||||
|
signedIn ? Icons.verified_user : Icons.account_circle,
|
||||||
|
size: 18,
|
||||||
|
color: signedIn ? const Color(0xFF0F766E) : Colors.black54,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_accessToken.isEmpty
|
signedIn ? accountLabel : 'Not signed in',
|
||||||
? 'Broker session: not signed in'
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
: 'Broker session: signed in',
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
if (signedIn &&
|
||||||
TextField(
|
_userEmail.isNotEmpty &&
|
||||||
controller: _googleIdTokenController,
|
_userEmail != accountLabel)
|
||||||
decoration: const InputDecoration(
|
Text(
|
||||||
labelText: 'Google ID token',
|
_userEmail,
|
||||||
helperText: 'Paste Google ID token, then sign in.',
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
obscureText: true,
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
Align(
|
if (signedIn)
|
||||||
alignment: Alignment.centerRight,
|
TextButton(onPressed: _signOut, child: const Text('Sign Out'))
|
||||||
child: FilledButton(
|
else
|
||||||
|
FilledButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final ok = await _signInWithGoogleIdToken();
|
final result = await _signInWithGoogle();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(
|
context,
|
||||||
content: Text(
|
).showSnackBar(SnackBar(content: Text(result.message)));
|
||||||
ok ? 'Broker sign-in successful.' : 'Broker sign-in failed.',
|
if (result.ok) {
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (ok) {
|
|
||||||
await _loadGallery();
|
await _loadGallery();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Sign In'),
|
child: const Text('Sign In'),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
paired ? Icons.lan : Icons.link_off,
|
||||||
|
size: 18,
|
||||||
|
color: paired ? const Color(0xFF0F766E) : Colors.black54,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
paired
|
||||||
|
? _agentConnected
|
||||||
|
? 'Agent paired and connected'
|
||||||
|
: 'Agent paired, waiting for connection'
|
||||||
|
: 'Agent not paired',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
if (_agentLanEndpoint.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
_agentLanEndpoint,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (!signedIn || !paired) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
!signedIn
|
||||||
|
? 'Sign in before pairing this phone with your agent.'
|
||||||
|
: 'Enter the pairing token from your agent.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
const Divider(height: 20),
|
const Divider(height: 20),
|
||||||
const Text(
|
|
||||||
'Direct upload is automatic using broker-provided agent LAN hints. '
|
|
||||||
'If direct fails, upload falls back to broker.',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _tokenController,
|
controller: _tokenController,
|
||||||
decoration: const InputDecoration(labelText: 'Pairing token'),
|
enabled: signedIn && (!paired || _editingPairing),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Pairing token',
|
||||||
|
helperText: paired
|
||||||
|
? 'Saved. Change the token only when re-pairing.'
|
||||||
|
: 'Required once for direct LAN upload.',
|
||||||
|
),
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Align(
|
Row(
|
||||||
alignment: Alignment.centerRight,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
child: FilledButton(
|
children: [
|
||||||
onPressed: _saveDirectUploadConfig,
|
if (paired && !_editingPairing)
|
||||||
child: const Text('Save LAN Settings'),
|
TextButton(
|
||||||
|
onPressed: signedIn
|
||||||
|
? () => setState(() => _editingPairing = true)
|
||||||
|
: null,
|
||||||
|
child: const Text('Change'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: canSavePairing ? _saveDirectUploadConfig : null,
|
||||||
|
child: Text(
|
||||||
|
paired ? 'LAN Settings Saved' : 'Save LAN Settings',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import FlutterMacOS
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
|
import google_sign_in_ios
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
|
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -184,6 +184,54 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
google_identity_services_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_identity_services_web
|
||||||
|
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.3+1"
|
||||||
|
google_sign_in:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: google_sign_in
|
||||||
|
sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.0"
|
||||||
|
google_sign_in_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_sign_in_android
|
||||||
|
sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.2.1"
|
||||||
|
google_sign_in_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_sign_in_ios
|
||||||
|
sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.9.0"
|
||||||
|
google_sign_in_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_sign_in_platform_interface
|
||||||
|
sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.0"
|
||||||
|
google_sign_in_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_sign_in_web
|
||||||
|
sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.4+4"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -284,26 +332,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker
|
name: leak_tracker
|
||||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.9"
|
version: "11.0.2"
|
||||||
leak_tracker_flutter_testing:
|
leak_tracker_flutter_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_flutter_testing
|
name: leak_tracker_flutter_testing
|
||||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.9"
|
version: "3.0.10"
|
||||||
leak_tracker_testing:
|
leak_tracker_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_testing
|
name: leak_tracker_testing
|
||||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.2"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -316,26 +364,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.18"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -513,10 +561,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.4"
|
version: "0.7.8"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -529,10 +577,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.2.0"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -574,5 +622,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.8.0 <4.0.0"
|
dart: ">=3.9.0-0 <4.0.0"
|
||||||
flutter: ">=3.32.0"
|
flutter: ">=3.32.0"
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ dependencies:
|
|||||||
image: ^4.2.0
|
image: ^4.2.0
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
shared_preferences: ^2.3.3
|
shared_preferences: ^2.3.3
|
||||||
|
google_sign_in: ^6.2.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -5,26 +5,17 @@
|
|||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||||
// tree, read text, and verify that the values of widget properties are correct.
|
// tree, read text, and verify that the values of widget properties are correct.
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:memory_by_clouderized/main.dart';
|
import 'package:memoric/main.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
testWidgets('MemoriC app renders', (WidgetTester tester) async {
|
||||||
// Build our app and trigger a frame.
|
// Build our app and trigger a frame.
|
||||||
await tester.pumpWidget(const MyApp());
|
await tester.pumpWidget(const MemoryApp());
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
expect(find.text('Memory by Clouderized'), findsOneWidget);
|
||||||
expect(find.text('0'), findsOneWidget);
|
expect(find.text('Gallery'), findsOneWidget);
|
||||||
expect(find.text('1'), findsNothing);
|
expect(find.text('Uploads'), findsOneWidget);
|
||||||
|
|
||||||
// Tap the '+' icon and trigger a frame.
|
|
||||||
await tester.tap(find.byIcon(Icons.add));
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
// Verify that our counter has incremented.
|
|
||||||
expect(find.text('0'), findsNothing);
|
|
||||||
expect(find.text('1'), findsOneWidget);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
2
memory
2
memory
Submodule memory updated: ab50b9a0f5...489949461d
Reference in New Issue
Block a user