From 58ed22a98327d65078bd08a353750028dd3f30b1 Mon Sep 17 00:00:00 2001 From: flofriday Date: Sun, 1 Aug 2021 21:02:02 +0200 Subject: [PATCH] Add search by matriculation number --- README.md | 115 ++++++++++++++++-------------- lib/main.dart | 123 ++++++++++++++++----------------- lib/screens/person_search.dart | 86 ++++++++++++++++++----- lib/widgets/person_card.dart | 29 ++++---- 4 files changed, 206 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index bf9b703..f4c9129 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # TU Wien Addressbook (unofficial) + An unofficial Android and iOS App to search [TU Wien](https://www.tuwien.at/en/) employees and students. ![Screenshot](screenshot.png) @@ -9,129 +10,143 @@ An unofficial Android and iOS App to search [TU Wien](https://www.tuwien.at/en/) API, the app itself is not official! ## Features -* Search [TU Wien](https://www.tuwien.at/en/) employees and students wihout opening TISS -* Filter search results -* Optional login (needed to get student information) -* Dark Mode + +- Search [TU Wien](https://www.tuwien.at/en/) employees and students wihout opening TISS +- Search by matriculation number, or name +- Filter search results +- Optional login (needed to get student information) +- Dark Mode ## Future Features + Things I should implement, but propably won't. -* Find students by their mattriculation number -* Support english -* Indicate if the user is logged in while searching -* Indicate that there are no student information without logging in -* Improve Settings UI +- Support english +- Indicate if the user is logged in while searching +- Indicate that there are no student information without logging in +- Improve Settings UI ## Build it yourself -1) [Install Flutter](https://flutter.dev/docs/get-started/install) -2) Clone this repository or download it. -3) Connect your phone to your computer. -4) Open the repository in your terminal. -5) For Android run: `flutter build apk --release && flutter install`
-For iOS you need to sign the App yourself to use it. Here is a -[Guide](https://medium.com/front-end-weekly/how-to-test-your-flutter-ios-app-on-your-ios-device-75924bfd75a8) -on how to do this. + +1. [Install Flutter](https://flutter.dev/docs/get-started/install) +2. Clone this repository or download it. +3. Connect your phone to your computer. +4. Open the repository in your terminal. +5. For Android run: `flutter build apk --release && flutter install`
+ For iOS you need to sign the App yourself to use it. Here is a + [Guide](https://medium.com/front-end-weekly/how-to-test-your-flutter-ios-app-on-your-ios-device-75924bfd75a8) + on how to do this. ## Frequently asked Question ### Why is the App in German? -Many of the TISS API responses are in German, so in the spirit of consistency + +Many of the TISS API responses are in German, so in the spirit of consistency I just designed the whole App in German. ### The search results are bad! + I know :pensive:
-There is just nothing I can do about it, because the App gets them from the TISS +There is just nothing I can do about it, because the App gets them from the TISS API. ### Why is the search so slow? -Again, there is nothing I can do about it as the API response just take a long -time. However, I figured out, the more specific you are, the faster the + +Again, there is nothing I can do about it as the API response just take a long +time. However, I figured out, the more specific you are, the faster the responses are. ### Where do you get the data from? -Actually, there is an official -[TISS REST API](https://tiss.tuwien.ac.at/api/dokumentation). While the API is -public (or semi-public, but I will come back to this later), the documentation + +Actually, there is an official +[TISS REST API](https://tiss.tuwien.ac.at/api/dokumentation). While the API is +public (or semi-public, but I will come back to this later), the documentation is not, so you need to be able to login to [TISS](https://tiss.tuwien.ac.at/). -However, if you don't have access to [TISS](https://tiss.tuwien.ac.at/), don't worry as -many aspects of the API are not documented anyway. For example, there is no -documentation about what the response json looks like or that you can set +However, if you don't have access to [TISS](https://tiss.tuwien.ac.at/), don't worry as +many aspects of the API are not documented anyway. For example, there is no +documentation about what the response json looks like or that you can set the language via a query parameter or that you will only receive student information if you are logged in. To search people on the API, I use the `/api/person/v22/psuche` endpoint. Here is a full example: + ``` https://tiss.tuwien.ac.at/api/person/v22/psuche?q=Panholz&max_treffer=50 ``` Here is a list of all query parameters: -| Name | Type | Datatype | Description | +| Name | Type | Datatype | Description | |-------------|:-----:|---------:|----------------------------------------------------------------------------------------------------------------| -| q | path | string | Searchterm | -| intern | query | bool | If internal persons(like students) should be included. This only works if you are logged in. Default is false. | -| max_treffer | query | int | Upper limit of results. Limit is 100, Default is 15. | -| locale | query | string | The language of (some?) fields of the result. It uses 2 letter codes like "de" or "en". Default is ???? | +| q | path | string | Searchterm | +| intern | query | bool | If internal persons(like students) should be included. This only works if you are logged in. Default is false. | +| max_treffer | query | int | Upper limit of results. Limit is 100, Default is 15. | +| locale | query | string | The language of (some?) fields of the result. It uses 2 letter codes like "de" or "en". Default is ???? | ### How does the app log in, to get student information? -So most sane developers, would design the authentication process of an API with an -API-token or maybe just let you send the username and password with every + +So most sane developers, would design the authentication process of an API with an +API-token or maybe just let you send the username and password with every request. (API-token is the way better solution) -Unfortunately, I couldn't find anything like that, so we will do it like the -Webinterface with cookies, parsing HTML, parsing URLs, following redirects and +Unfortunately, I couldn't find anything like that, so we will do it like the +Webinterface with cookies, parsing HTML, parsing URLs, following redirects and many more cookies. Sounds fun? I guarantee you **IT IS NOT**! #### STEP 0: Requirements -Many requests will set you a cookie, so just save them and if you do a request + +Many requests will set you a cookie, so just save them and if you do a request in the future to the same domain just send them with the request. #### STEP 1: Create a session and collect the AuthState -First start with a `GET` request to + +First start with a `GET` request to `https://tiss.tuwien.ac.at/admin/authentifizierung`. The server will answer with -the status 302 (Redirect). Now you follow multiple redirects (like 5 or 6) +the status 302 (Redirect). Now you follow multiple redirects (like 5 or 6) until the server finally answers with 200 (OK). Now parse the URL on which you finally landed on, because in the URL should be a query parameter called `AuthState` which we need to save for the next request. #### STEP 2: Get the first login cookie and parsing HTML -Next, we need to make a `POST` request to + +Next, we need to make a `POST` request to `https://idp.zid.tuwien.ac.at/simplesaml/module.php/core/loginuserpass.php`. The data we send with that request is form-encoded and there are the following fields: |Name|Content| |--|--| -| username | *your TU Wien username* | -| password | *your TU Wien password* | -| totp | *empty (This field is for Two Factor Authentication*| -| AuthState | *the AuthState we collected in Step 1* | +| username | _your TU Wien username_ | +| password | _your TU Wien password_ | +| totp | _empty (This field is for Two Factor Authentication_| +| AuthState | _the AuthState we collected in Step 1_ | The server should now respond with HTML. In this HTML is a Form with two hidden fields: `SAMLResponse` and `RelayState`. You now need to parse the HTML and get the content of both fields and save them. #### Step 3: Get the second login cookie + Make a `POST` request to `https://login.tuwien.ac.at/auth/postResponse` with the following form-encoded data: |Name|Content| |--|--| -| SAMLResponse | *the SAMLResponse from Step 2* | -| RelayState | *the RelayState from Step 2* | +| SAMLResponse | _the SAMLResponse from Step 2_ | +| RelayState | _the RelayState from Step 2_ | The server will respond with status 303 (Redirect), save that url. #### Step 4: Get the final TISS cookie -With `GET` requests follow the redirects until the server responds with + +With `GET` requests follow the redirects until the server responds with 200 (OK), starting with the location we got at the end of Step 3. -While following these redirects the server will eventually set you cookie called -`TISS_AUTH`. This is the one you ~wanted~ needed all along. Now, all you have -to do is make requests to the TISS API and send this cookie with them. +While following these redirects the server will eventually set you cookie called +`TISS_AUTH`. This is the one you ~wanted~ needed all along. Now, all you have +to do is make requests to the TISS API and send this cookie with them. 🥳 🎉 diff --git a/lib/main.dart b/lib/main.dart index 05e6f6b..037b56e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ class App extends StatelessWidget { title: 'TU Addressbuch', locale: Locale('de', 'AT'), theme: ThemeData( + brightness: Brightness.light, accentColor: Colors.indigo[400], primarySwatch: Colors.blueGrey, scaffoldBackgroundColor: Colors.blueGrey[50], @@ -94,73 +95,69 @@ class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - //Padding(padding: EdgeInsets.only(top: 100)), - Expanded(child: Container()), - Center( - child: Text("Suche", style: Theme.of(context).textTheme.headline3), - ), - Padding( - padding: EdgeInsets.all(16), - child: ElevatedButton( - /* - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(100.0), - ), - color: Theme.of(context).cardColor, - */ - onPressed: () { - showSearch(context: context, delegate: PersonSearch()); - }, - child: Padding( - padding: EdgeInsets.all(8), - child: Row( - children: [ - Icon( - Icons.search, - size: 24, - ), - Padding( - padding: EdgeInsets.only(left: 10), - ), - Text( - "Studierende, Angestellte", - style: TextStyle(fontSize: 20), - ), - ], - ), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + //Padding(padding: EdgeInsets.only(top: 100)), + Expanded(child: Container()), + Center( + child: Text("Suche", style: Theme.of(context).textTheme.headline3), + ), + Padding( + padding: EdgeInsets.all(16), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + shape: new RoundedRectangleBorder( + borderRadius: new BorderRadius.circular(30.0), + )), + onPressed: () { + showSearch(context: context, delegate: PersonSearch()); + }, + child: Padding( + padding: EdgeInsets.all(8), + child: Row( + children: [ + Icon( + Icons.search, + size: 24, + ), + Padding( + padding: EdgeInsets.only(left: 10), + ), + Text( + "Studierende, Angestellte", + style: TextStyle(fontSize: 20), + ), + ], ), ), ), - Expanded( - child: Container(), - flex: 2, - ), - Padding( - padding: EdgeInsets.all(8), - child: RichText( - text: TextSpan( - style: DefaultTextStyle.of(context).style.copyWith( - fontSize: Theme.of(context).textTheme.caption!.fontSize), - children: [ - TextSpan( - text: "Made with ❤️ by ", - ), - TextSpan( - text: "flofriday", - //style: TextStyle(decoration: TextDecoration.underline), - recognizer: TapGestureRecognizer() - ..onTap = () { - print("tap"); - launchInBrowser("https://github.com/flofriday"); - }), - ]), - ), + ), + + Expanded( + child: Container(), + flex: 2, + ), + Padding( + padding: EdgeInsets.all(8), + child: RichText( + text: TextSpan( + style: DefaultTextStyle.of(context).style.copyWith( + fontSize: Theme.of(context).textTheme.caption!.fontSize), + children: [ + TextSpan( + text: "Made with ❤️ by ", + ), + TextSpan( + text: "flofriday", + //style: TextStyle(decoration: TextDecoration.underline), + recognizer: TapGestureRecognizer() + ..onTap = () { + print("tap"); + launchInBrowser("https://github.com/flofriday"); + }), + ]), ), - ], - ), + ), + ]), ); } } diff --git a/lib/screens/person_search.dart b/lib/screens/person_search.dart index 88bce3a..414b506 100644 --- a/lib/screens/person_search.dart +++ b/lib/screens/person_search.dart @@ -14,7 +14,7 @@ import 'package:http/http.dart' as http; class PersonSearch extends SearchDelegate { TissLoginManager _tissManager = TissLoginManager(); SuggestionManager _suggestionManager = SuggestionManager(); - Map _cache = Map(); + Map> _cache = Map(); bool _employeeFilter = false; bool _studentFilter = false; bool _femaleFilter = false; @@ -34,7 +34,8 @@ class PersonSearch extends SearchDelegate { return emojis[(_rng.nextInt(emojis.length))]; } - Future _makeRequest(String query) async { + /// Make a request to search for the name of a person + Future _makePersonRequest(String query) async { // Get the Cookies String cookies = await _tissManager.getCookies()!; var headers = {"Cookie": cookies}; @@ -52,31 +53,77 @@ class PersonSearch extends SearchDelegate { return res; } + /// Make a request to search for a person by their matriculation number + Future _makeMnrRequest(String query) async { + // Get the Cookies + String cookies = await _tissManager.getCookies()!; + var headers = {"Cookie": cookies}; + + // Make the request + Uri apiSearchUri = + Uri.https("tiss.tuwien.ac.at", "/api/person/v22/mnr/$query", { + "preview_picture_uri": "true", + "intern": "true", + "locale": "de", + }); + http.Response res = await http.get(apiSearchUri, headers: headers); + return res; + } + // Tries to download the results from TISS, returns null if the mission went // sideways. - Future _getData(String query) async { + Future?> _getData(String query) async { // Check the cache if (_cache.containsKey(query)) return _cache[query]; // Since we get new data now, we should reset the position of the scroll _scrollOffset = 0; - // Make the request - http.Response resp = await _makeRequest(query); - if (resp.statusCode != 200) return null; + // Check if the query is a matriculation number + RegExp mrnRegex = RegExp("[01]?[0-9]{7}"); + if (mrnRegex.hasMatch(query)) { + // Load by matriculation number + + // Make the request + http.Response resp = await _makeMnrRequest(query); + if (resp.statusCode == 404) return []; + if (resp.statusCode != 200) return null; + + // Parse the json + var map = jsonDecode(resp.body); + Person data = Person.fromJson(map); - // Parse the json - var map = jsonDecode(resp.body); - Tiss data = Tiss.fromJson(map); + // Add the data to the cache and return + _cache[query] = [data]; + return [data]; + } else { + // Load by name - // Add the data to the cache and return - _cache[query] = data; - return data; + // Make the request + http.Response resp = await _makePersonRequest(query); + if (resp.statusCode != 200) return null; + + // Parse the json + var map = jsonDecode(resp.body); + Tiss data = Tiss.fromJson(map); + + // Add the data to the cache and return + _cache[query] = data.results; + return data.results; + } } @override ThemeData appBarTheme(BuildContext context) { - return Theme.of(context); + final ThemeData theme = Theme.of(context); + if (theme.brightness == Brightness.dark) return theme; + + return theme.copyWith( + primaryColor: theme.cardColor, + primaryIconTheme: theme.primaryIconTheme.copyWith(color: Colors.grey), + primaryColorBrightness: MediaQuery.of(context).platformBrightness, + primaryTextTheme: theme.textTheme, + ); } @override @@ -104,14 +151,15 @@ class PersonSearch extends SearchDelegate { @override Widget buildResults(BuildContext context) { - Future data = _getData(query); + Future?> data = _getData(query); _suggestionManager.addSuggestion(query); return StatefulBuilder( builder: (BuildContext context, StateSetter setState) { - return FutureBuilder( + return FutureBuilder?>( future: data, - builder: (BuildContext context, AsyncSnapshot snapshot) { + builder: + (BuildContext context, AsyncSnapshot?> snapshot) { // Check for errors if (snapshot.hasError) { return SafeArea( @@ -149,7 +197,7 @@ class PersonSearch extends SearchDelegate { } // Check if the server answered successfully - Tiss? data = snapshot.data; + List? data = snapshot.data; if (data == null) { return Center( child: @@ -157,7 +205,7 @@ class PersonSearch extends SearchDelegate { } // Show a error if there are no results - if (data.results.length == 0) { + if (data.length == 0) { return SafeArea( child: Center( child: Column( @@ -184,7 +232,7 @@ class PersonSearch extends SearchDelegate { } // Filter the results - Iterable unfiltered = data.results; + Iterable unfiltered = data; if (_studentFilter) { unfiltered = unfiltered.where((Person p) => p.student != null); } diff --git a/lib/widgets/person_card.dart b/lib/widgets/person_card.dart index a34906f..b1f298c 100644 --- a/lib/widgets/person_card.dart +++ b/lib/widgets/person_card.dart @@ -129,23 +129,22 @@ class PersonInfoCard extends StatelessWidget { }, ), ), - if (person.tissUri != null) - Expanded( - child: TextButton( - child: Column( - children: [ - Icon(Icons.school), - Text( - "TISS", - style: Theme.of(context).textTheme.caption, - ), - ], - ), - onPressed: () { - launchInBrowser(person.getTissUrl()); - }, + Expanded( + child: TextButton( + child: Column( + children: [ + Icon(Icons.school), + Text( + "TISS", + style: Theme.of(context).textTheme.caption, + ), + ], ), + onPressed: () { + launchInBrowser(person.getTissUrl()); + }, ), + ), ]), ), ],