Major additions since the initial rebuild: - New tabs: Trash, Shares, Recent, configurable tab visibility/order/default - Nextcloud sharing: search users/teams, internal link, email shares, public links, "others with access" - in its own Share bottom sheet - Details bottom sheet (Info/Versions/Activity) with a persistent peek panel in the media viewer, reachable from selection, swipe, and the media viewer - Files/Photos: tap-and-hold multi-select with a sticky selection toolbar, swipe actions (favorite/delete/share), sticky sort/filter controls, list/grid views, breadcrumb chips, whole-account Photos view - Media viewer: pinch/double-tap zoom on images, predictive-back support, fixed PDF zoom-out floor - Settings: Material You theming incl. AMOLED black, configurable bottom bar tabs, swipe actions, media seek bar style, and folder-listing cache policy (never/periodic/manual) to cut down on refetching - Login flow moved to an in-app Chrome Custom Tab with retry-tolerant polling - Replaced ad hoc widgets with native Material 3 equivalents (toggle IconButtons, SliverAppBar selection toolbar, SearchBar, drag handles) - Rebrand: app renamed to Noo throughout, package id -> dev.ayushya.noo, new adaptive launcher icon Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
118 lines
3.5 KiB
Dart
118 lines
3.5 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
class LoginFlowInit {
|
|
final Uri loginUrl;
|
|
final Uri pollEndpoint;
|
|
final String pollToken;
|
|
|
|
const LoginFlowInit({
|
|
required this.loginUrl,
|
|
required this.pollEndpoint,
|
|
required this.pollToken,
|
|
});
|
|
}
|
|
|
|
class LoginFlowResult {
|
|
final String serverUrl;
|
|
final String loginName;
|
|
final String appPassword;
|
|
|
|
const LoginFlowResult({
|
|
required this.serverUrl,
|
|
required this.loginName,
|
|
required this.appPassword,
|
|
});
|
|
}
|
|
|
|
/// Implements Nextcloud's "Login Flow v2": the app never sees the user's
|
|
/// real password. It asks the server for a one-time login URL, the user
|
|
/// authorizes in their browser, and the app polls until the server hands
|
|
/// back a scoped app password.
|
|
/// https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
|
class LoginFlowService {
|
|
static const _userAgent = 'Noo/1.0';
|
|
|
|
static String normalizeServerUrl(String input) {
|
|
var url = input.trim();
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
url = 'https://$url';
|
|
}
|
|
if (url.endsWith('/')) {
|
|
url = url.substring(0, url.length - 1);
|
|
}
|
|
return url;
|
|
}
|
|
|
|
static Future<LoginFlowInit> initiate(String serverUrl) async {
|
|
final cleanUrl = normalizeServerUrl(serverUrl);
|
|
final endpoint = Uri.parse('$cleanUrl/index.php/login/v2');
|
|
debugPrint('[LoginFlow] Initiating login flow at $endpoint');
|
|
|
|
final response = await http.post(
|
|
endpoint,
|
|
headers: {'User-Agent': _userAgent, 'OCS-APIRequest': 'true'},
|
|
);
|
|
|
|
if (response.statusCode != 200) {
|
|
throw Exception(
|
|
'Could not start login flow (HTTP ${response.statusCode}). '
|
|
'Check the server address and that it is a reachable Nextcloud instance.',
|
|
);
|
|
}
|
|
|
|
final data = jsonDecode(response.body);
|
|
final loginUrl = data['login'] as String?;
|
|
final poll = data['poll'] as Map?;
|
|
final pollToken = poll?['token'] as String?;
|
|
final pollEndpoint = poll?['endpoint'] as String?;
|
|
|
|
if (loginUrl == null || pollToken == null || pollEndpoint == null) {
|
|
throw Exception('Server response did not include login flow details.');
|
|
}
|
|
|
|
return LoginFlowInit(
|
|
loginUrl: Uri.parse(loginUrl),
|
|
pollEndpoint: Uri.parse(pollEndpoint),
|
|
pollToken: pollToken,
|
|
);
|
|
}
|
|
|
|
/// Performs a single poll attempt. Returns null while the user has not
|
|
/// finished authorizing yet (server responds 404 during that window).
|
|
static Future<LoginFlowResult?> poll(Uri pollEndpoint, String token) async {
|
|
final response = await http.post(
|
|
pollEndpoint,
|
|
headers: {
|
|
'User-Agent': _userAgent,
|
|
'OCS-APIRequest': 'true',
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: {'token': token},
|
|
);
|
|
|
|
if (response.statusCode == 404) {
|
|
return null;
|
|
}
|
|
if (response.statusCode != 200) {
|
|
throw Exception('Login poll failed (HTTP ${response.statusCode}).');
|
|
}
|
|
|
|
final data = jsonDecode(response.body);
|
|
final server = data['server'] as String?;
|
|
final loginName = data['loginName'] as String?;
|
|
final appPassword = data['appPassword'] as String?;
|
|
|
|
if (server == null || loginName == null || appPassword == null) {
|
|
throw Exception('Server response did not include app credentials.');
|
|
}
|
|
|
|
return LoginFlowResult(
|
|
serverUrl: server,
|
|
loginName: loginName,
|
|
appPassword: appPassword,
|
|
);
|
|
}
|
|
}
|