/* * SPDX-FileCopyrightText: 2019-2021 Vishesh Handa * * SPDX-License-Identifier: AGPL-3.0-or-later */ import 'package:dots_indicator/dots_indicator.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:function_types/function_types.dart'; import 'package:git_setup/git_config.dart'; import 'package:gitjournal/analytics/analytics.dart'; import 'package:gitjournal/error_reporting.dart'; import 'package:gitjournal/l10n.dart'; import 'package:gitjournal/logger/logger.dart'; import 'package:gitjournal/repository.dart'; import 'package:gitjournal/settings/storage_config.dart'; import 'package:gitjournal/setup/clone.dart'; import 'package:gitjournal/setup/clone_auto_select.dart'; import 'package:gitjournal/utils/utils.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; import 'package:time/time.dart'; import 'package:universal_io/io.dart' show Platform, Directory; import 'package:url_launcher/url_launcher.dart'; import 'apis/githost_factory.dart'; import 'autoconfigure.dart'; import 'button.dart'; import 'clone_url.dart'; import 'cloning.dart'; import 'git_transfer_progress.dart'; import 'keygen.dart'; import 'loading_error.dart'; import 'repo_selector.dart'; import 'sshkey.dart'; class GitHostSetupScreen extends StatefulWidget { static const routePath = '/setupRemoteGit'; final String repoFolderName; final String remoteName; final Func2> onCompletedFunction; final Keygen keygen; final ISetupProviders providers; const GitHostSetupScreen({ required this.repoFolderName, required this.remoteName, required this.onCompletedFunction, required this.keygen, required this.providers, }); @override GitHostSetupScreenState createState() { return GitHostSetupScreenState(); } } enum PageChoice0 { Unknown, KnownProvider, CustomProvider } enum PageChoice1 { Unknown, Manual, Auto } enum KeyGenerationChoice { Unknown, AutoGenerated, UserProvided } class GitHostSetupScreenState extends State { var _pageCount = 1; final _pageChoice = [ PageChoice0.Unknown, PageChoice1.Unknown, ]; var _keyGenerationChoice = KeyGenerationChoice.Unknown; var _gitHostType = GitHostType.Unknown; GitHost? _gitHost; late GitHostRepo _gitHostRepo; String _autoConfigureMessage = ""; String _autoConfigureErrorMessage = ""; var _gitCloneUrl = ""; var _cloneProgress = GitTransferProgress(); String? _gitCloneErrorMessage = ""; var _publicKey = ""; final _pageController = PageController(); int _currentPageIndex = 0; UserInfo? _userInfo; Widget _buildPage(BuildContext context, int pos) { assert(_pageCount >= 1); if (pos == 0) { return GitHostChoicePage( onKnownGitHost: (GitHostType gitHostType) { setState(() { _gitHostType = gitHostType; _gitCloneErrorMessage = ""; _autoConfigureErrorMessage = ""; _autoConfigureMessage = ""; _pageChoice[0] = PageChoice0.KnownProvider; _pageCount = pos + 2; _nextPage(); }); }, onCustomGitHost: () { setState(() { _pageChoice[0] = PageChoice0.CustomProvider; _pageCount = pos + 2; _nextPage(); }); }, ); } if (pos == 1) { assert(_pageChoice[0] != PageChoice0.Unknown); if (_pageChoice[0] == PageChoice0.CustomProvider) { return GitCloneUrlPage( doneFunction: (String sshUrl) { setState(() { _gitCloneUrl = sshUrl; _pageCount = pos + 2; _nextPage(); }); }, initialValue: _gitCloneUrl, ); } return GitHostAutoConfigureChoicePage( onDone: (GitHostSetupType setupType) { if (setupType == GitHostSetupType.Manual) { setState(() { _pageCount = pos + 2; _pageChoice[1] = PageChoice1.Manual; _nextPage(); }); } else if (setupType == GitHostSetupType.Auto) { setState(() { _pageCount = pos + 2; _pageChoice[1] = PageChoice1.Auto; _nextPage(); }); } }, ); } if (pos == 2) { if (_pageChoice[0] == PageChoice0.CustomProvider) { return GitHostSetupKeyChoicePage( onGenerateKeys: () { setState(() { _keyGenerationChoice = KeyGenerationChoice.AutoGenerated; _pageCount = pos + 2; _nextPage(); _generateSshKey(context); }); }, onUserProvidedKeys: () { setState(() { _keyGenerationChoice = KeyGenerationChoice.UserProvided; _pageCount = pos + 2; _nextPage(); }); }, ); } assert(_pageChoice[1] != PageChoice1.Unknown); if (_pageChoice[1] == PageChoice1.Manual) { return GitCloneUrlKnownProviderPage( doneFunction: (String sshUrl) { setState(() { _pageCount = pos + 2; _gitCloneUrl = sshUrl; _nextPage(); }); }, launchCreateUrlPage: _launchCreateRepoPage, gitHostType: _gitHostType, initialValue: _gitCloneUrl, ); } else if (_pageChoice[1] == PageChoice1.Auto) { return GitHostSetupAutoConfigurePage( gitHostType: _gitHostType, onDone: (GitHost? gitHost, UserInfo? userInfo) { if (gitHost == null) { setState(() { // FIXME: Set an error when it failed to get the gitHost }); } else { setState(() { _gitHost = gitHost; _userInfo = userInfo; _pageCount = pos + 2; _nextPage(); }); } }, providers: widget.providers, ); } } if (pos == 3) { if (_pageChoice[0] == PageChoice0.CustomProvider) { assert(_keyGenerationChoice != KeyGenerationChoice.Unknown); if (_keyGenerationChoice == KeyGenerationChoice.AutoGenerated) { return GitHostSetupSshKeyUnknownProviderPage( doneFunction: () { setState(() { _pageCount = pos + 2; _nextPage(); _gitCloneErrorMessage = ""; _startGitClone(context); }); }, regenerateFunction: () { setState(() { _publicKey = ""; }); _generateSshKey(context); }, publicKey: _publicKey, copyKeyFunction: _copyKeyToClipboard, ); } else if (_keyGenerationChoice == KeyGenerationChoice.UserProvided) { return GitHostUserProvidedKeysPage( doneFunction: (String publicKey, String privateKey, String password) async { var gitConfig = widget.providers.readGitConfig(context); gitConfig.sshPublicKey = publicKey; gitConfig.sshPrivateKey = privateKey; gitConfig.sshPassword = password; await gitConfig.save(); setState(() { _publicKey = publicKey; _pageCount = pos + 2; _nextPage(); _gitCloneErrorMessage = ""; _startGitClone(context); }); }, ); } } if (_pageChoice[1] == PageChoice1.Manual) { return GitHostSetupKeyChoicePage( onGenerateKeys: () { setState(() { _keyGenerationChoice = KeyGenerationChoice.AutoGenerated; _pageCount = pos + 2; _nextPage(); _generateSshKey(context); }); }, onUserProvidedKeys: () { setState(() { _keyGenerationChoice = KeyGenerationChoice.UserProvided; _pageCount = pos + 2; _nextPage(); }); }, ); } else if (_pageChoice[1] == PageChoice1.Auto) { if (_gitHost == null || _userInfo == null) { return const Icon(Icons.error, size: 64); } return GitHostSetupRepoSelector( gitHost: _gitHost!, userInfo: _userInfo!, onDone: (GitHostRepo repo) { // close keyboard FocusManager.instance.primaryFocus?.unfocus(); setState(() { _gitHostRepo = repo; _pageCount = pos + 2; _nextPage(); _completeAutoConfigure(); }); }, ); } assert(false); } if (pos == 4) { if (_pageChoice[0] == PageChoice0.CustomProvider) { return GitHostCloningPage( errorMessage: _gitCloneErrorMessage, cloneProgress: _cloneProgress, ); } if (_pageChoice[1] == PageChoice1.Manual) { assert(_keyGenerationChoice != KeyGenerationChoice.Unknown); if (_keyGenerationChoice == KeyGenerationChoice.AutoGenerated) { return GitHostSetupSshKeyKnownProviderPage( doneFunction: () { setState(() { _pageCount = 6; _nextPage(); _gitCloneErrorMessage = ""; _startGitClone(context); }); }, regenerateFunction: () { setState(() { _publicKey = ""; }); _generateSshKey(context); }, publicKey: _publicKey, copyKeyFunction: _copyKeyToClipboard, openDeployKeyPage: _launchDeployKeyPage, ); } else if (_keyGenerationChoice == KeyGenerationChoice.UserProvided) { return GitHostUserProvidedKeysPage( doneFunction: (publicKey, privateKey, password) async { var gitConfig = widget.providers.readGitConfig(context); gitConfig.sshPublicKey = publicKey; gitConfig.sshPrivateKey = privateKey; gitConfig.sshPassword = password; await gitConfig.save(); setState(() { _publicKey = publicKey; _pageCount = pos + 2; _nextPage(); _gitCloneErrorMessage = ""; _startGitClone(context); }); }, ); } } else if (_pageChoice[1] == PageChoice1.Auto) { return GitHostSetupLoadingErrorPage( loadingMessage: _autoConfigureMessage, errorMessage: _autoConfigureErrorMessage, ); } } if (pos == 5) { return GitHostSetupLoadingErrorPage( loadingMessage: context.loc.setupCloning, errorMessage: _gitCloneErrorMessage, ); } assert(_pageChoice[0] != PageChoice0.CustomProvider); assert(false, "Pos is $pos"); return Container(); } @override Widget build(BuildContext context) { var pageView = PageView.builder( controller: _pageController, itemBuilder: _buildPage, itemCount: _pageCount, onPageChanged: (int pageNum) { setState(() { _currentPageIndex = pageNum; _pageCount = _currentPageIndex + 1; }); }, ); var body = Container( width: double.infinity, height: double.infinity, child: Stack( alignment: FractionalOffset.bottomCenter, children: [ pageView, DotsIndicator( dotsCount: _pageCount, position: _currentPageIndex.toDouble(), decorator: DotsDecorator( activeColor: Theme.of(context).primaryColorDark, ), ), ], ), padding: const EdgeInsets.fromLTRB(16.0, 0, 16.0, 16.0), ); var scaffold = Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).scaffoldBackgroundColor, elevation: 0.0, ), body: body, ); return WillPopScope( onWillPop: () async { if (_currentPageIndex != 0) { _previousPage(); return false; } _removeRemote(); return true; }, child: scaffold, ); } Future _removeRemote() async { var repo = context.read(); var r1 = await repo.ensureValidRepo(); if (r1.isFailure) { showResultError(context, r1); } var r2 = await repo.removeRemote(widget.remoteName); if (r2.isFailure) { showResultError(context, r2); } } Future _previousPage() { return _pageController.previousPage( duration: 200.milliseconds, curve: Curves.easeIn, ); } Future _nextPage() { return _pageController.nextPage( duration: 200.milliseconds, curve: Curves.easeIn, ); } void _generateSshKey(BuildContext context) { if (_publicKey.isNotEmpty) { return; } var keyType = widget.providers.readGitConfig(context).sshKeyType; var comment = "GitJournal-" + Platform.operatingSystem + "-" + DateTime.now().toIso8601String().substring(0, 10); // only the date widget.keygen .generate(type: keyType, comment: comment) .then((SshKey? sshKey) { var gitConfig = widget.providers.readGitConfig(context); gitConfig.sshPublicKey = sshKey!.publicKey; gitConfig.sshPrivateKey = sshKey.privateKey; gitConfig.sshPassword = sshKey.password; gitConfig.save(); setState(() { _publicKey = sshKey.publicKey; Log.d("PublicKey: " + _publicKey); _copyKeyToClipboard(context); }); }); } void _copyKeyToClipboard(BuildContext context) { Clipboard.setData(ClipboardData(text: _publicKey)); showSnackbar(context, context.loc.setupSshKeyCopied); } Future _launchDeployKeyPage() async { var canLaunch = _gitCloneUrl.startsWith("git@github.com:") || _gitCloneUrl.startsWith("git@gitlab.com:"); if (!canLaunch) { return; } var lastIndex = _gitCloneUrl.lastIndexOf(".git"); if (lastIndex == -1) { lastIndex = _gitCloneUrl.length; } var repoName = _gitCloneUrl.substring(_gitCloneUrl.lastIndexOf(":") + 1, lastIndex); final gitHubUrl = 'https://github.com/' + repoName + '/settings/keys/new'; final gitLabUrl = 'https://gitlab.com/' + repoName + '/-/settings/repository/#js-deploy-keys-settings'; try { if (_gitCloneUrl.startsWith("git@github.com:")) { Log.i("Launching $gitHubUrl"); var _ = await launchUrl( Uri.parse(gitHubUrl), mode: LaunchMode.externalApplication, ); } else if (_gitCloneUrl.startsWith("git@gitlab.com:")) { Log.i("Launching $gitLabUrl"); var _ = await launchUrl( Uri.parse(gitLabUrl), mode: LaunchMode.externalApplication, ); } } catch (err, stack) { Log.d('_launchDeployKeyPage: ' + err.toString()); Log.d(stack.toString()); } } Future _launchCreateRepoPage() async { assert(_gitHostType != GitHostType.Unknown); try { if (_gitHostType == GitHostType.GitHub) { var _ = await launchUrl( Uri.parse("https://github.com/new"), mode: LaunchMode.externalApplication, ); } else if (_gitHostType == GitHostType.GitLab) { var _ = await launchUrl( Uri.parse("https://gitlab.com/projects/new"), mode: LaunchMode.externalApplication, ); } } catch (err, stack) { // FIXME: Error handling? Log.d("_launchCreateRepoPage: " + err.toString()); Log.d(stack.toString()); } } Future _startGitClone(BuildContext context) async { if (_gitCloneErrorMessage!.isNotEmpty) { return; } var repo = context.read(); var basePath = repo.gitBaseDirectory; var gitConfig = widget.providers.readGitConfig(context); var repoPath = p.join(basePath, widget.repoFolderName); Log.i("RepoPath: $repoPath"); var cloneR = await cloneRemote( cloneUrl: _gitCloneUrl, remoteName: widget.remoteName, repoPath: repoPath, sshPassword: gitConfig.sshPassword, sshPrivateKey: gitConfig.sshPrivateKey, sshPublicKey: gitConfig.sshPublicKey, authorEmail: gitConfig.gitAuthorEmail, authorName: gitConfig.gitAuthor, progressUpdate: (GitTransferProgress p) { setState(() { _cloneProgress = p; }); }, ); if (cloneR.isFailure) { Log.e("Failed to clone", ex: cloneR.error, stacktrace: cloneR.stackTrace); var error = cloneR.error.toString(); _removeRemote(); setState(() { logEvent(Event.GitHostSetupGitCloneError, parameters: { 'error': error, }); _gitCloneErrorMessage = error; }); return; } logEvent( Event.GitHostSetupComplete, parameters: _buildOnboardingAnalytics(), ); var storageConfig = Provider.of(context, listen: false); var folderName = folderNameFromCloneUrl(_gitCloneUrl); if (folderName != widget.repoFolderName) { var newRepoPath = p.join(basePath, folderName); var i = 0; while (Directory(newRepoPath).existsSync()) { i++; newRepoPath = p.join(basePath, folderName + "_$i"); } folderName = p.basename(newRepoPath); var repoPath = p.join(basePath, widget.repoFolderName); Log.i("Renaming $repoPath --> $newRepoPath"); var _ = await Directory(repoPath).rename(newRepoPath); storageConfig.folderName = p.basename(newRepoPath); } Log.i("calling onComplete $folderName ${widget.remoteName}"); await widget.onCompletedFunction(folderName, widget.remoteName); Navigator.pop(context); } Future _completeAutoConfigure() async { Log.d("Starting autoconfigure copletion"); try { Log.i("Generating SSH Key"); setState(() { _autoConfigureMessage = context.loc.setupSshKeyGenerate; }); var keyType = widget.providers.readGitConfig(context).sshKeyType; var sshKey = await widget.keygen.generate( type: keyType, comment: "GitJournal", ); if (sshKey == null) { // FIXME: Handle case when sshKey generation failed return; } var gitConfig = widget.providers.readGitConfig(context); gitConfig.sshPublicKey = sshKey.publicKey; gitConfig.sshPrivateKey = sshKey.privateKey; gitConfig.sshPassword = sshKey.password; await gitConfig.save(); setState(() { _publicKey = sshKey.publicKey; }); Log.i("Adding as a deploy key"); _autoConfigureMessage = context.loc.setupSshKeyAddDeploy; await _gitHost! .addDeployKey(_publicKey, _gitHostRepo.fullName) .throwOnError(); } on Exception catch (e, stacktrace) { _handleGitHostException(e, stacktrace); return; } setState(() { _gitCloneUrl = _gitHostRepo.cloneUrl; _pageCount += 1; _nextPage(); _gitCloneErrorMessage = ""; _startGitClone(context); }); } void _handleGitHostException(Exception e, StackTrace stacktrace) { Log.d("GitHostSetupAutoConfigureComplete: " + e.toString()); setState(() { _autoConfigureErrorMessage = e.toString(); logEvent( Event.GitHostSetupError, parameters: { 'errorMessage': _autoConfigureErrorMessage, }, ); logException(e, stacktrace); }); } Map _buildOnboardingAnalytics() { var map = {}; if (_gitCloneUrl.contains("github.com")) { map["host_type"] = "GitHub"; } else if (_gitCloneUrl.contains("gitlab.org")) { map["host_type"] = "GitLab.org"; } else if (_gitCloneUrl.contains("gitlab")) { map["host_type"] = "GitLab"; } var ch0 = _pageChoice[0] as PageChoice0; map["provider_choice"] = ch0.toString().replaceFirst("PageChoice0.", ""); var ch1 = _pageChoice[1] as PageChoice1; map["setup_manner"] = ch1.toString().replaceFirst("PageChoice1.", ""); map["key_generation"] = _keyGenerationChoice .toString() .replaceFirst("KeyGenerationChoice.", ""); return map; } } class GitHostChoicePage extends StatelessWidget { final Func1 onKnownGitHost; final Func0 onCustomGitHost; const GitHostChoicePage({ required this.onKnownGitHost, required this.onCustomGitHost, }); @override Widget build(BuildContext context) { return Column( children: [ Text( context.loc.setupHostTitle, style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 16.0), GitHostSetupButton( text: "GitHub", iconUrl: 'assets/icon/github-icon.png', onPressed: () { onKnownGitHost(GitHostType.GitHub); }, ), const SizedBox(height: 8.0), GitHostSetupButton( text: "GitLab", iconUrl: 'assets/icon/gitlab-icon.png', onPressed: () async { onKnownGitHost(GitHostType.GitLab); }, ), const SizedBox(height: 8.0), GitHostSetupButton( text: context.loc.setupHostCustom, onPressed: () async { onCustomGitHost(); }, ), ], mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, ); } } enum GitHostSetupType { Auto, Manual, } var _isDesktop = Platform.isLinux || Platform.isMacOS || Platform.isWindows; class GitHostAutoConfigureChoicePage extends StatelessWidget { final Func1 onDone; const GitHostAutoConfigureChoicePage({required this.onDone}); @override Widget build(BuildContext context) { return Column( children: [ Text( context.loc.setupAutoConfigureTitle, style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 16.0), if (!_isDesktop) GitHostSetupButton( text: context.loc.setupAutoConfigureAutomatic, onPressed: () { onDone(GitHostSetupType.Auto); }, ), if (!_isDesktop) const SizedBox(height: 8.0), GitHostSetupButton( text: context.loc.setupAutoConfigureManual, onPressed: () async { onDone(GitHostSetupType.Manual); }, ), ], mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, ); } }