feat(auth): new authentication flow using cookies and webview in android
@ -6,7 +6,7 @@ buildscript {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:4.1.0'
|
classpath 'com.android.tools.build:gradle:7.2.1'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip
|
||||||
|
Before Width: | Height: | Size: 744 KiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 149 KiB |
BIN
assets/tutorial/step-3.png
Normal file
After Width: | Height: | Size: 137 KiB |
Before Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 61 KiB |
Before Width: | Height: | Size: 30 KiB |
@ -26,19 +26,6 @@ import 'package:spotube/provider/SpotifyRequests.dart';
|
|||||||
import 'package:spotube/provider/UserPreferences.dart';
|
import 'package:spotube/provider/UserPreferences.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
|
|
||||||
List<String> spotifyScopes = [
|
|
||||||
"playlist-modify-public",
|
|
||||||
"playlist-modify-private",
|
|
||||||
"playlist-read-private",
|
|
||||||
"user-library-read",
|
|
||||||
"user-library-modify",
|
|
||||||
"user-read-private",
|
|
||||||
"user-read-email",
|
|
||||||
"user-follow-read",
|
|
||||||
"user-follow-modify",
|
|
||||||
"playlist-read-collaborative"
|
|
||||||
];
|
|
||||||
|
|
||||||
final selectedIndexState = StateProvider((ref) => 0);
|
final selectedIndexState = StateProvider((ref) => 0);
|
||||||
|
|
||||||
class Home extends HookConsumerWidget {
|
class Home extends HookConsumerWidget {
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:introduction_screen/introduction_screen.dart';
|
import 'package:introduction_screen/introduction_screen.dart';
|
||||||
import 'package:spotube/components/Login/LoginForm.dart';
|
import 'package:spotube/components/Login/TokenLoginForms.dart';
|
||||||
import 'package:spotube/components/Shared/Hyperlink.dart';
|
import 'package:spotube/components/Shared/Hyperlink.dart';
|
||||||
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
||||||
import 'package:spotube/provider/Auth.dart';
|
import 'package:spotube/provider/Auth.dart';
|
||||||
@ -30,12 +28,12 @@ class LoginTutorial extends ConsumerWidget {
|
|||||||
back: const Text("Previous"),
|
back: const Text("Previous"),
|
||||||
showBackButton: true,
|
showBackButton: true,
|
||||||
overrideDone: TextButton(
|
overrideDone: TextButton(
|
||||||
child: const Text("Done"),
|
|
||||||
onPressed: auth.isLoggedIn
|
onPressed: auth.isLoggedIn
|
||||||
? () {
|
? () {
|
||||||
GoRouter.of(context).go("/");
|
GoRouter.of(context).go("/");
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
child: const Text("Done"),
|
||||||
),
|
),
|
||||||
pages: [
|
pages: [
|
||||||
PageViewModel(
|
PageViewModel(
|
||||||
@ -48,12 +46,12 @@ class LoginTutorial extends ConsumerWidget {
|
|||||||
style: Theme.of(context).textTheme.bodyText1,
|
style: Theme.of(context).textTheme.bodyText1,
|
||||||
),
|
),
|
||||||
Hyperlink(
|
Hyperlink(
|
||||||
"developer.spotify.com/dashboard ",
|
"accounts.spotify.com ",
|
||||||
"https://developer.spotify.com/dashboard",
|
"https://accounts.spotify.com",
|
||||||
style: Theme.of(context).textTheme.bodyText1!,
|
style: Theme.of(context).textTheme.bodyText1!,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
"and Login if you're not logged in",
|
"and Login/Sign up if you're not logged in",
|
||||||
style: Theme.of(context).textTheme.bodyText1,
|
style: Theme.of(context).textTheme.bodyText1,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -63,89 +61,21 @@ class LoginTutorial extends ConsumerWidget {
|
|||||||
title: "Step 2",
|
title: "Step 2",
|
||||||
image: Image.asset("assets/tutorial/step-2.png"),
|
image: Image.asset("assets/tutorial/step-2.png"),
|
||||||
bodyWidget: Text(
|
bodyWidget: Text(
|
||||||
"Now, create an Spotify Developer Application by Clicking on the \"CREATE AN APP\" button. Give it a name and description too",
|
"1. Once you're logged in, press F12 or Mouse Right Click > Inspect to Open the Browser devtools.\n2. Then go the \"Application\" Tab (Chrome, Edge, Brave etc..) or \"Storage\" Tab (Firefox, Palemoon etc..)\n3. Go to the \"Cookies\" section then the \"https://accounts.spotify.com\" subsection",
|
||||||
textAlign: TextAlign.left,
|
textAlign: TextAlign.left,
|
||||||
style: Theme.of(context).textTheme.bodyText1,
|
style: Theme.of(context).textTheme.bodyText1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PageViewModel(
|
PageViewModel(
|
||||||
title: "Step 3 [Really Important!]",
|
title: "Step 3",
|
||||||
bodyWidget: Column(
|
image: Image.asset(
|
||||||
children: [
|
"assets/tutorial/step-3.png",
|
||||||
Text(
|
),
|
||||||
"Tap on the \"EDIT SETTINGS\" Button & navigate to \"Redirect URIs\" section",
|
bodyWidget: Text(
|
||||||
|
"Copy the values of \"sp_dc\" and \"sp_key\" Cookies",
|
||||||
textAlign: TextAlign.left,
|
textAlign: TextAlign.left,
|
||||||
style: Theme.of(context).textTheme.bodyText1,
|
style: Theme.of(context).textTheme.bodyText1,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
|
||||||
Wrap(
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
"Add ",
|
|
||||||
style: Theme.of(context).textTheme.bodyText1,
|
|
||||||
),
|
|
||||||
OutlinedButton(
|
|
||||||
child: Text(
|
|
||||||
"http://localhost:4304/auth/spotify/callback",
|
|
||||||
style: Theme.of(context).textTheme.bodyText1?.copyWith(
|
|
||||||
color: Theme.of(context).primaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
shape: const RoundedRectangleBorder(),
|
|
||||||
),
|
|
||||||
onPressed: () async {
|
|
||||||
await Clipboard.setData(
|
|
||||||
const ClipboardData(
|
|
||||||
text: "http://localhost:4304/auth/spotify/callback",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
width: 300,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
content: Text(
|
|
||||||
"Copied http://localhost:4304/auth/spotify/callback to clipboard",
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
" to \"Redirect URIs\"",
|
|
||||||
style: Theme.of(context).textTheme.bodyText1,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Wrap(
|
|
||||||
runSpacing: 10,
|
|
||||||
spacing: 10,
|
|
||||||
children: [
|
|
||||||
ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 500),
|
|
||||||
child: Image.asset(
|
|
||||||
"assets/tutorial/step-3a.jpg",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 700),
|
|
||||||
child: Image.asset(
|
|
||||||
"assets/tutorial/step-3b.jpg",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PageViewModel(
|
|
||||||
title: "Step 4",
|
|
||||||
image: Image.asset("assets/tutorial/step-4.jpg"),
|
|
||||||
body:
|
|
||||||
"Finally, reveal the \"Client Secret\" by clicking on the \"SHOW CLIENT SECRET\" text\n Copy the Client ID & Client Secret then Paste them in the next Screen",
|
|
||||||
),
|
),
|
||||||
if (auth.isLoggedIn)
|
if (auth.isLoggedIn)
|
||||||
PageViewModel(
|
PageViewModel(
|
||||||
@ -163,11 +93,11 @@ class LoginTutorial extends ConsumerWidget {
|
|||||||
bodyWidget: Column(
|
bodyWidget: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"Paste the Copied \"Client ID\" and \"Client Secret\" Here",
|
"Paste the copied \"sp_dc\" and \"sp_key\" values in the respective fields",
|
||||||
style: Theme.of(context).textTheme.bodyText1,
|
style: Theme.of(context).textTheme.bodyText1,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
LoginForm(),
|
const TokenLoginForm(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -1,19 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotube/components/Login/LoginForm.dart';
|
import 'package:spotube/components/Login/TokenLoginForms.dart';
|
||||||
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
|
||||||
import 'package:spotube/hooks/useBreakpoints.dart';
|
import 'package:spotube/hooks/useBreakpoints.dart';
|
||||||
import 'package:spotube/models/Logger.dart';
|
|
||||||
|
|
||||||
class Login extends HookConsumerWidget {
|
class TokenLogin extends HookConsumerWidget {
|
||||||
Login({Key? key}) : super(key: key);
|
const TokenLogin({Key? key}) : super(key: key);
|
||||||
final log = getLogger(Login);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final breakpoint = useBreakpoints();
|
final breakpoint = useBreakpoints();
|
||||||
|
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
@ -39,8 +36,8 @@ class Login extends HookConsumerWidget {
|
|||||||
style: Theme.of(context).textTheme.caption,
|
style: Theme.of(context).textTheme.caption,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
LoginForm(
|
TokenLoginForm(
|
||||||
onDone: () => GoRouter.of(context).pop(),
|
onDone: () => GoRouter.of(context).go("/"),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Wrap(
|
Wrap(
|
@ -1,40 +1,22 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotube/models/Logger.dart';
|
|
||||||
import 'package:spotube/provider/Auth.dart';
|
import 'package:spotube/provider/Auth.dart';
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
|
|
||||||
class LoginForm extends HookConsumerWidget {
|
class TokenLoginForm extends HookConsumerWidget {
|
||||||
final void Function()? onDone;
|
final void Function()? onDone;
|
||||||
LoginForm({this.onDone, Key? key}) : super(key: key);
|
const TokenLoginForm({
|
||||||
|
Key? key,
|
||||||
final log = getLogger(LoginForm);
|
this.onDone,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
Auth authState = ref.watch(authProvider);
|
Auth authState = ref.watch(authProvider);
|
||||||
final clientIdController = useTextEditingController();
|
final directCodeController = useTextEditingController();
|
||||||
final clientSecretController = useTextEditingController();
|
final keyCodeController = useTextEditingController();
|
||||||
final fieldError = useState(false);
|
final mounted = useIsMounted();
|
||||||
|
|
||||||
Future handleLogin(Auth authState) async {
|
|
||||||
try {
|
|
||||||
if (clientIdController.value.text == "" ||
|
|
||||||
clientSecretController.value.text == "") {
|
|
||||||
fieldError.value = true;
|
|
||||||
}
|
|
||||||
await ServiceUtils.oauthLogin(
|
|
||||||
ref.read(authProvider),
|
|
||||||
clientId: clientIdController.value.text,
|
|
||||||
clientSecret: clientSecretController.value.text,
|
|
||||||
).then(
|
|
||||||
(value) => onDone?.call(),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
log.e("[Login.handleLogin] $e");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ConstrainedBox(
|
return ConstrainedBox(
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints(
|
||||||
@ -43,27 +25,27 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: clientIdController,
|
controller: directCodeController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: "Spotify Client ID",
|
hintText: "Spotify \"sp_dc\" Cookie",
|
||||||
label: Text("ClientID"),
|
label: Text("sp_dc Cookie"),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.visiblePassword,
|
keyboardType: TextInputType.visiblePassword,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
TextField(
|
TextField(
|
||||||
controller: clientSecretController,
|
controller: keyCodeController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: "Spotify Client Secret",
|
hintText: "Spotify \"sp_key\" Cookie",
|
||||||
label: Text("Client Secret"),
|
label: Text("sp_key Cookie"),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.visiblePassword,
|
keyboardType: TextInputType.visiblePassword,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (clientSecretController.text.isEmpty ||
|
if (keyCodeController.text.isEmpty ||
|
||||||
clientIdController.text.isEmpty) {
|
directCodeController.text.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text("Please fill in all fields"),
|
content: Text("Please fill in all fields"),
|
||||||
@ -72,7 +54,18 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await handleLogin(authState);
|
final cookieHeader =
|
||||||
|
"sp_dc=${directCodeController.text}; sp_key=${keyCodeController.text}";
|
||||||
|
final body = await ServiceUtils.getAccessToken(cookieHeader);
|
||||||
|
|
||||||
|
authState.setAuthState(
|
||||||
|
accessToken: body.accessToken,
|
||||||
|
authCookie: cookieHeader,
|
||||||
|
expiration: body.expiration,
|
||||||
|
);
|
||||||
|
if (mounted()) {
|
||||||
|
onDone?.call();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
child: const Text("Submit"),
|
child: const Text("Submit"),
|
||||||
)
|
)
|
70
lib/components/Login/WebViewLogin.dart
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotube/provider/Auth.dart';
|
||||||
|
import 'package:spotube/utils/platform.dart';
|
||||||
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
|
|
||||||
|
class WebViewLogin extends HookConsumerWidget {
|
||||||
|
const WebViewLogin({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final mounted = useIsMounted();
|
||||||
|
final auth = ref.watch(authProvider);
|
||||||
|
|
||||||
|
if (kIsDesktop) {
|
||||||
|
const Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Text('This feature is not available on desktop'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: InAppWebView(
|
||||||
|
initialUrlRequest: URLRequest(
|
||||||
|
url: Uri.parse("https://accounts.spotify.com/"),
|
||||||
|
),
|
||||||
|
androidOnPermissionRequest: (controller, origin, resources) async {
|
||||||
|
return PermissionRequestResponse(
|
||||||
|
resources: resources,
|
||||||
|
action: PermissionRequestResponseAction.GRANT);
|
||||||
|
},
|
||||||
|
onLoadStop: (controller, action) async {
|
||||||
|
if (action == null) return;
|
||||||
|
String url = action.toString();
|
||||||
|
if (url.endsWith("/")) {
|
||||||
|
url = url.substring(0, url.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url == "https://accounts.spotify.com/en/status") {
|
||||||
|
final cookies =
|
||||||
|
await CookieManager.instance().getCookies(url: action);
|
||||||
|
final cookieHeader =
|
||||||
|
cookies.fold<String>("", (previousValue, element) {
|
||||||
|
if (element.name == "sp_dc" || element.name == "sp_key") {
|
||||||
|
return "$previousValue; ${element.name}=${element.value}";
|
||||||
|
}
|
||||||
|
return previousValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
final body = await ServiceUtils.getAccessToken(cookieHeader);
|
||||||
|
auth.setAuthState(
|
||||||
|
accessToken: body.accessToken,
|
||||||
|
authCookie: cookieHeader,
|
||||||
|
expiration: body.expiration,
|
||||||
|
);
|
||||||
|
if (mounted()) {
|
||||||
|
GoRouter.of(context).go("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -166,10 +166,12 @@ class _SpotubeState extends ConsumerState<Spotube> with WidgetsBindingObserver {
|
|||||||
@override
|
@override
|
||||||
void didChangeMetrics() {
|
void didChangeMetrics() {
|
||||||
super.didChangeMetrics();
|
super.didChangeMetrics();
|
||||||
if (localStorage == null ||
|
final windowSameDimension = kIsMobile
|
||||||
(prevSize?.width == appWindow.size.width &&
|
? false
|
||||||
prevSize?.height == appWindow.size.height) ||
|
: prevSize?.width == appWindow.size.width &&
|
||||||
kIsMobile) return;
|
prevSize?.height == appWindow.size.height;
|
||||||
|
|
||||||
|
if (localStorage == null || windowSameDimension || kIsMobile) return;
|
||||||
localStorage!.setString(
|
localStorage!.setString(
|
||||||
LocalStorageKeys.windowSizeInfo,
|
LocalStorageKeys.windowSizeInfo,
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
|
@ -3,12 +3,14 @@ import 'package:spotify/spotify.dart';
|
|||||||
import 'package:spotube/components/Album/AlbumView.dart';
|
import 'package:spotube/components/Album/AlbumView.dart';
|
||||||
import 'package:spotube/components/Artist/ArtistProfile.dart';
|
import 'package:spotube/components/Artist/ArtistProfile.dart';
|
||||||
import 'package:spotube/components/Home/Home.dart';
|
import 'package:spotube/components/Home/Home.dart';
|
||||||
import 'package:spotube/components/Login/Login.dart';
|
|
||||||
import 'package:spotube/components/Login/LoginTutorial.dart';
|
import 'package:spotube/components/Login/LoginTutorial.dart';
|
||||||
|
import 'package:spotube/components/Login/TokenLogin.dart';
|
||||||
import 'package:spotube/components/Player/PlayerView.dart';
|
import 'package:spotube/components/Player/PlayerView.dart';
|
||||||
import 'package:spotube/components/Playlist/PlaylistView.dart';
|
import 'package:spotube/components/Playlist/PlaylistView.dart';
|
||||||
import 'package:spotube/components/Settings/Settings.dart';
|
import 'package:spotube/components/Settings/Settings.dart';
|
||||||
import 'package:spotube/components/Shared/SpotubePageRoute.dart';
|
import 'package:spotube/components/Shared/SpotubePageRoute.dart';
|
||||||
|
import 'package:spotube/utils/platform.dart';
|
||||||
|
import 'package:spotube/components/Login/WebViewLogin.dart';
|
||||||
|
|
||||||
GoRouter createGoRouter() => GoRouter(
|
GoRouter createGoRouter() => GoRouter(
|
||||||
routes: [
|
routes: [
|
||||||
@ -19,7 +21,7 @@ GoRouter createGoRouter() => GoRouter(
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: "/login",
|
path: "/login",
|
||||||
pageBuilder: (context, state) => SpotubePage(
|
pageBuilder: (context, state) => SpotubePage(
|
||||||
child: Login(),
|
child: kIsMobile ? const WebViewLogin() : const TokenLogin(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
30
lib/models/SpotifySpotubeCredentials.dart
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
class SpotifySpotubeCredentials {
|
||||||
|
String clientId;
|
||||||
|
String accessToken;
|
||||||
|
DateTime expiration;
|
||||||
|
bool isAnonymous;
|
||||||
|
|
||||||
|
SpotifySpotubeCredentials({
|
||||||
|
required this.clientId,
|
||||||
|
required this.accessToken,
|
||||||
|
required this.expiration,
|
||||||
|
required this.isAnonymous,
|
||||||
|
});
|
||||||
|
|
||||||
|
SpotifySpotubeCredentials.fromJson(Map<String, dynamic> json)
|
||||||
|
: clientId = json['clientId'],
|
||||||
|
accessToken = json['accessToken'],
|
||||||
|
expiration = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
json['accessTokenExpirationTimestampMs'],
|
||||||
|
),
|
||||||
|
isAnonymous = json['isAnonymous'];
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'clientId': clientId,
|
||||||
|
'accessToken': accessToken,
|
||||||
|
'accessTokenExpirationTimestampMs': expiration.millisecondsSinceEpoch,
|
||||||
|
'isAnonymous': isAnonymous,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,90 +1,117 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotube/utils/PersistedChangeNotifier.dart';
|
import 'package:spotube/utils/PersistedChangeNotifier.dart';
|
||||||
|
import 'package:spotube/utils/platform.dart';
|
||||||
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
|
|
||||||
class Auth extends PersistedChangeNotifier {
|
class Auth extends PersistedChangeNotifier {
|
||||||
String? _clientId;
|
|
||||||
String? _clientSecret;
|
|
||||||
String? _accessToken;
|
String? _accessToken;
|
||||||
String? _refreshToken;
|
|
||||||
DateTime? _expiration;
|
DateTime? _expiration;
|
||||||
|
String? _authCookie;
|
||||||
|
|
||||||
Auth() : super();
|
Timer? _refresher;
|
||||||
|
|
||||||
|
Auth() : super() {
|
||||||
|
_refresher = _createRefresher();
|
||||||
|
}
|
||||||
|
|
||||||
String? get clientId => _clientId;
|
|
||||||
String? get clientSecret => _clientSecret;
|
|
||||||
String? get accessToken => _accessToken;
|
String? get accessToken => _accessToken;
|
||||||
String? get refreshToken => _refreshToken;
|
|
||||||
DateTime? get expiration => _expiration;
|
DateTime? get expiration => _expiration;
|
||||||
|
String? get authCookie => _authCookie;
|
||||||
|
|
||||||
bool get isAnonymous =>
|
bool get isAnonymous => accessToken == null && authCookie == null;
|
||||||
_clientId == null &&
|
|
||||||
_clientSecret == null &&
|
|
||||||
accessToken == null &&
|
|
||||||
refreshToken == null;
|
|
||||||
|
|
||||||
bool get isLoggedIn => !isAnonymous && _expiration != null;
|
bool get isLoggedIn => !isAnonymous && _expiration != null;
|
||||||
|
bool get isExpired =>
|
||||||
|
_expiration != null && _expiration!.isBefore(DateTime.now());
|
||||||
|
|
||||||
|
Duration get expiresIn =>
|
||||||
|
_expiration?.difference(DateTime.now()) ?? Duration.zero;
|
||||||
|
|
||||||
|
_refresh() async {
|
||||||
|
final data = await ServiceUtils.getAccessToken(authCookie!);
|
||||||
|
_accessToken = data.accessToken;
|
||||||
|
_expiration = data.expiration;
|
||||||
|
_restartRefresher();
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer? _createRefresher() {
|
||||||
|
if (expiration == null || !isExpired || authCookie == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_refresher?.cancel();
|
||||||
|
return Timer(expiresIn, _refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _restartRefresher() {
|
||||||
|
_refresher?.cancel();
|
||||||
|
_refresher = _createRefresher();
|
||||||
|
}
|
||||||
|
|
||||||
void setAuthState({
|
void setAuthState({
|
||||||
bool safe = true,
|
bool safe = true,
|
||||||
String? clientId,
|
|
||||||
String? clientSecret,
|
|
||||||
String? refreshToken,
|
|
||||||
String? accessToken,
|
String? accessToken,
|
||||||
DateTime? expiration,
|
DateTime? expiration,
|
||||||
|
String? authCookie,
|
||||||
}) {
|
}) {
|
||||||
if (safe) {
|
if (safe) {
|
||||||
if (clientId != null) _clientId = clientId;
|
|
||||||
if (clientSecret != null) _clientSecret = clientSecret;
|
|
||||||
if (refreshToken != null) _refreshToken = refreshToken;
|
|
||||||
if (accessToken != null) _accessToken = accessToken;
|
if (accessToken != null) _accessToken = accessToken;
|
||||||
if (expiration != null) _expiration = expiration;
|
if (expiration != null) {
|
||||||
} else {
|
|
||||||
_clientId = clientId;
|
|
||||||
_clientSecret = clientSecret;
|
|
||||||
_accessToken = accessToken;
|
|
||||||
_refreshToken = refreshToken;
|
|
||||||
_expiration = expiration;
|
_expiration = expiration;
|
||||||
|
_restartRefresher();
|
||||||
|
}
|
||||||
|
if (authCookie != null) _authCookie = authCookie;
|
||||||
|
} else {
|
||||||
|
_accessToken = accessToken;
|
||||||
|
_expiration = expiration;
|
||||||
|
_authCookie = authCookie;
|
||||||
|
|
||||||
|
_restartRefresher();
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
updatePersistence();
|
updatePersistence();
|
||||||
}
|
}
|
||||||
|
|
||||||
void logout() {
|
void logout() {
|
||||||
_clientId = null;
|
|
||||||
_clientSecret = null;
|
|
||||||
_accessToken = null;
|
_accessToken = null;
|
||||||
_refreshToken = null;
|
|
||||||
_expiration = null;
|
_expiration = null;
|
||||||
|
_authCookie = null;
|
||||||
|
_refresher?.cancel();
|
||||||
|
_refresher = null;
|
||||||
|
if (kIsMobile) {
|
||||||
|
WebStorageManager.instance().android.deleteAllData();
|
||||||
|
CookieManager.instance().deleteAllCookies();
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
updatePersistence(clearNullEntries: true);
|
updatePersistence(clearNullEntries: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return "Auth(clientId: $clientId, clientSecret: $clientSecret, accessToken: $accessToken, refreshToken: $refreshToken, expiration: $expiration, isLoggedIn: $isLoggedIn)";
|
return "Auth(accessToken: $accessToken, expiration: $expiration, isLoggedIn: $isLoggedIn, isAnonymous: $isAnonymous, authCookie: $authCookie)";
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<void> loadFromLocal(Map<String, dynamic> map) {
|
FutureOr<void> loadFromLocal(Map<String, dynamic> map) {
|
||||||
_clientId = map["clientId"];
|
|
||||||
_clientSecret = map["clientSecret"];
|
|
||||||
_accessToken = map["accessToken"];
|
_accessToken = map["accessToken"];
|
||||||
_refreshToken = map["refreshToken"];
|
|
||||||
_expiration = map["expiration"] != null
|
_expiration = map["expiration"] != null
|
||||||
? DateTime.tryParse(map["expiration"])
|
? DateTime.tryParse(map["expiration"])
|
||||||
: _expiration;
|
: _expiration;
|
||||||
|
_authCookie = map["authCookie"];
|
||||||
|
_restartRefresher();
|
||||||
|
if (isExpired) {
|
||||||
|
_refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<Map<String, dynamic>> toMap() {
|
FutureOr<Map<String, dynamic>> toMap() {
|
||||||
return {
|
return {
|
||||||
"clientId": _clientId,
|
|
||||||
"clientSecret": _clientSecret,
|
|
||||||
"accessToken": _accessToken,
|
"accessToken": _accessToken,
|
||||||
"refreshToken": _refreshToken,
|
|
||||||
"expiration": _expiration.toString(),
|
"expiration": _expiration.toString(),
|
||||||
|
"authCookie": _authCookie,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Home/Home.dart';
|
|
||||||
import 'package:spotube/models/generated_secrets.dart';
|
import 'package:spotube/models/generated_secrets.dart';
|
||||||
import 'package:spotube/provider/Auth.dart';
|
import 'package:spotube/provider/Auth.dart';
|
||||||
import 'package:spotube/utils/primitive_utils.dart';
|
import 'package:spotube/utils/primitive_utils.dart';
|
||||||
@ -8,30 +7,15 @@ import 'package:spotube/utils/primitive_utils.dart';
|
|||||||
final spotifyProvider = Provider<SpotifyApi>((ref) {
|
final spotifyProvider = Provider<SpotifyApi>((ref) {
|
||||||
Auth authState = ref.watch(authProvider);
|
Auth authState = ref.watch(authProvider);
|
||||||
final anonCred = PrimitiveUtils.getRandomElement(spotifySecrets);
|
final anonCred = PrimitiveUtils.getRandomElement(spotifySecrets);
|
||||||
SpotifyApiCredentials apiCredentials = authState.isAnonymous
|
|
||||||
? SpotifyApiCredentials(
|
if (authState.isAnonymous) {
|
||||||
|
return SpotifyApi(
|
||||||
|
SpotifyApiCredentials(
|
||||||
anonCred["clientId"],
|
anonCred["clientId"],
|
||||||
anonCred["clientSecret"],
|
anonCred["clientSecret"],
|
||||||
)
|
),
|
||||||
: SpotifyApiCredentials(
|
|
||||||
authState.clientId,
|
|
||||||
authState.clientSecret,
|
|
||||||
accessToken: authState.accessToken,
|
|
||||||
refreshToken: authState.refreshToken,
|
|
||||||
expiration: authState.expiration,
|
|
||||||
scopes: spotifyScopes,
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return SpotifyApi(
|
return SpotifyApi.withAccessToken(authState.accessToken!);
|
||||||
apiCredentials,
|
|
||||||
onCredentialsRefreshed: (credentials) {
|
|
||||||
authState.setAuthState(
|
|
||||||
clientId: credentials.clientId,
|
|
||||||
clientSecret: credentials.clientSecret,
|
|
||||||
accessToken: credentials.accessToken,
|
|
||||||
refreshToken: credentials.refreshToken,
|
|
||||||
expiration: credentials.expiration,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
@ -4,11 +4,11 @@ import 'dart:io';
|
|||||||
import 'package:html/dom.dart';
|
import 'package:html/dom.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Home/Home.dart';
|
|
||||||
import 'package:spotube/models/LocalStorageKeys.dart';
|
import 'package:spotube/models/LocalStorageKeys.dart';
|
||||||
import 'package:spotube/models/Logger.dart';
|
import 'package:spotube/models/Logger.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:spotube/models/LyricsModels.dart';
|
import 'package:spotube/models/LyricsModels.dart';
|
||||||
|
import 'package:spotube/models/SpotifySpotubeCredentials.dart';
|
||||||
import 'package:spotube/models/SpotubeTrack.dart';
|
import 'package:spotube/models/SpotubeTrack.dart';
|
||||||
import 'package:spotube/models/generated_secrets.dart';
|
import 'package:spotube/models/generated_secrets.dart';
|
||||||
import 'package:spotube/provider/Auth.dart';
|
import 'package:spotube/provider/Auth.dart';
|
||||||
@ -176,6 +176,7 @@ abstract class ServiceUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Deprecated("Use getAccessToken instead")
|
||||||
static Future<String?> connectIpc(String authUri, String redirectUri) async {
|
static Future<String?> connectIpc(String authUri, String redirectUri) async {
|
||||||
try {
|
try {
|
||||||
logger.i("[connectIpc][Launching]: $authUri");
|
logger.i("[connectIpc][Launching]: $authUri");
|
||||||
@ -220,6 +221,9 @@ abstract class ServiceUtils {
|
|||||||
|
|
||||||
static const authRedirectUri = "http://localhost:4304/auth/spotify/callback";
|
static const authRedirectUri = "http://localhost:4304/auth/spotify/callback";
|
||||||
|
|
||||||
|
/// Use [getAccessToken] instead
|
||||||
|
/// This method will be removed in the next major release
|
||||||
|
@Deprecated("Use getAccessToken instead")
|
||||||
static Future<void> oauthLogin(Auth auth,
|
static Future<void> oauthLogin(Auth auth,
|
||||||
{required String clientId, required String clientSecret}) async {
|
{required String clientId, required String clientSecret}) async {
|
||||||
try {
|
try {
|
||||||
@ -229,8 +233,9 @@ abstract class ServiceUtils {
|
|||||||
final credentials = SpotifyApiCredentials(clientId, clientSecret);
|
final credentials = SpotifyApiCredentials(clientId, clientSecret);
|
||||||
final grant = SpotifyApi.authorizationCodeGrant(credentials);
|
final grant = SpotifyApi.authorizationCodeGrant(credentials);
|
||||||
|
|
||||||
final authUri = grant.getAuthorizationUrl(Uri.parse(authRedirectUri),
|
final authUri = grant.getAuthorizationUrl(
|
||||||
scopes: spotifyScopes);
|
Uri.parse(authRedirectUri),
|
||||||
|
);
|
||||||
|
|
||||||
final responseUri = await connectIpc(authUri.toString(), authRedirectUri);
|
final responseUri = await connectIpc(authUri.toString(), authRedirectUri);
|
||||||
SharedPreferences localStorage = await SharedPreferences.getInstance();
|
SharedPreferences localStorage = await SharedPreferences.getInstance();
|
||||||
@ -261,13 +266,13 @@ abstract class ServiceUtils {
|
|||||||
clientSecret,
|
clientSecret,
|
||||||
);
|
);
|
||||||
|
|
||||||
auth.setAuthState(
|
// auth.setAuthState(
|
||||||
clientId: clientId,
|
// clientId: clientId,
|
||||||
clientSecret: clientSecret,
|
// clientSecret: clientSecret,
|
||||||
accessToken: accessToken,
|
// accessToken: accessToken,
|
||||||
refreshToken: refreshToken,
|
// refreshToken: refreshToken,
|
||||||
expiration: expiration,
|
// expiration: expiration,
|
||||||
);
|
// );
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
logger.e("oauthLogin", e, stack);
|
logger.e("oauthLogin", e, stack);
|
||||||
rethrow;
|
rethrow;
|
||||||
@ -360,4 +365,26 @@ abstract class ServiceUtils {
|
|||||||
|
|
||||||
return subtitle;
|
return subtitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<SpotifySpotubeCredentials> getAccessToken(
|
||||||
|
String cookieHeader) async {
|
||||||
|
try {
|
||||||
|
final res = await http.get(
|
||||||
|
Uri.parse(
|
||||||
|
"https://open.spotify.com/get_access_token?reason=transport&productType=web_player",
|
||||||
|
),
|
||||||
|
headers: {
|
||||||
|
"Cookie": cookieHeader,
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36"
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return SpotifySpotubeCredentials.fromJson(
|
||||||
|
jsonDecode(res.body),
|
||||||
|
);
|
||||||
|
} catch (e, stack) {
|
||||||
|
logger.e("getAccessToken", e, stack);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
28
pubspec.lock
@ -540,6 +540,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.18.5+1"
|
version: "0.18.5+1"
|
||||||
|
flutter_inappwebview:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "5.4.3+7"
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@ -641,13 +648,6 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.3"
|
version: "1.1.3"
|
||||||
hookified_infinite_scroll_pagination:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: hookified_infinite_scroll_pagination
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "0.1.0"
|
|
||||||
hooks_riverpod:
|
hooks_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -690,13 +690,6 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.0"
|
version: "3.2.0"
|
||||||
infinite_scroll_pagination:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: infinite_scroll_pagination
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "3.2.0"
|
|
||||||
introduction_screen:
|
introduction_screen:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -1129,13 +1122,6 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.99"
|
version: "0.0.99"
|
||||||
sliver_tools:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sliver_tools
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "0.2.6"
|
|
||||||
source_gen:
|
source_gen:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -56,6 +56,7 @@ dependencies:
|
|||||||
visibility_detector: ^0.3.3
|
visibility_detector: ^0.3.3
|
||||||
fl_query: ^0.3.0
|
fl_query: ^0.3.0
|
||||||
fl_query_hooks: ^0.3.0
|
fl_query_hooks: ^0.3.0
|
||||||
|
flutter_inappwebview: ^5.4.3+7
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|