Upgrade to Pro — share decks privately, control downloads, hide ads and more …

The technical solutions behind Öppna Skolplattf...

The technical solutions behind Öppna Skolplattformen

After years of massive failures in the official system for public schools in Stockholm (Stockholm Stad Skolplattform), a group of developers got together in December 2020 to build an alternative app that presents the same information in ways that make life easier for parents.. The project was named Öppna Skolplattformen and released with huge success on Google Play and App Store in January 2021. The entire project was also made available as open-source on GitHub.

In this session, you will learn about the technologies in the app, why we made the choices we did, and the challenges we faced while building it. You'll hear some amazing facts about the Stockholm Skolplattform and the solutions we needed to build in order to work with and around the system. This is also a great success story about user-driven development and we hope this can serve as a great example for future digitalization of the public sector.

Erik Hellman

May 03, 2022
Tweet

More Decks by Erik Hellman

Other Decks in Programming

Transcript

  1. @ErikHellman - Head of Development @ Iteam Solutions The technical

    solutions behind Öppna Skolplattformen How we built a successful app for a hostile API provider…
  2. @ErikHellman - Head of Development @ Iteam Solutions How I

    became a parent without having any children…
  3. api-hooks import React from 'react' import { ApiProvider } from

    '@skolplattformen/api-hooks' import init from '@skolplattformen/embedded-api' import { CookieManager } from '@react-native-community/cookies' import AsyncStorage from '@react-native-async-storage/async-storage' import { RootComponent } from './components/root' const api = init(fetch, () => CookieManager.clearAll()) export default () => ( <ApiProvider api={api} storage={AsyncStorage}> <RootComponent /> </ApiProvider> )
  4. api-hooks import { useApi } from ‘@skolplattformen/api-hooks' export default function

    LoginController () { const { api, isLoggedIn } = useApi() api.on('login', () => { /* do login stuff */ }) api.on('logout', () => { /* do logout stuff */ }) const [personalNumber, setPersonalNumber] = useState() const [bankIdStatus, setBankIdStatus] = useState('') const doLogin = async () => { const status = await api.login(personalNumber) openBankID(status.token) status.on('PENDING', () => { setBankIdStatus('BankID app not yet opened') }) status.on('USER_SIGN', () => { setBankIdStatus('BankID app is open') }) status.on('OK', () => { setBankIdStatus('BankID signed. NOTE! User is NOT yet logged in!') }) status.on('ERROR', (err) => { setBankIdStatus('BankID failed') }) }) return ( … ) }
  5. export const login = (personalNumber: string) => `https://login003.stockholm.se/...&personalNumber=${personalNumber}&_=${Date.now()}`; export const

    loginStatus = (order: string) => `https://login003.stockholm.se/...&verifyorder=${order}&_=${Date.now()}`; export const loginCookie = "https://login003.stockholm.se/..."; export const children = "https://etjanst.stockholm.se/.../GetChildren"; export const calendar = (childId: string) => `https://etjanst.stockholm.se/.../GetSchoolCalender?childId=${childId}&rowLimit=50`; export const classmates = (childId: string) => `https://etjanst.stockholm.se/.../GetStudentsByClass?studentId=${childId}`; export const user = "https://etjanst.stockholm.se/.../getuserdata"; export const news = (childId: string) => `https://etjanst.stockholm.se/.../GetNewsOverview?childId=${childId}`; export const newsDetails = (childId: string, newsId: string) => `https://etjanst.stockholm.se/.../GetNewsArticle?newsItemId=${newsId}&childId=${childId}`; export const image = (url: string) => `https://etjanst.stockholm.se/vardnadshavare/inloggad2/NewsBanner?url=${url}`; export const notifications = (childId: string) => `https://etjanst.stockholm.se/.../GetNotification?childId=${childId}`; export const menu = (childId: string) => `https://etjanst.stockholm.se/.../GetMatsedelRSS?childId=${childId}`; export const schedule = (childId: string, fromDate: string, endDate: string) => `https://etjanst.stockholm.se/.../?childId=${childId}&startDate=${fromDate}&endDate=${endDate}`; Routes - February 2021
  6. export const login = (personalNumber?: string) => { const baseUrl

    = "https://login003.stockholm.se/..."; const optionalPersonalNumber = personalNumber === undefined ? "" : `&personalNumber=${personalNumber}`; return `${baseUrl}&initialize=bankid${optionalPersonalNumber}&_=${Date.now()}`; }; export const loginStatus = (order: string) => `https://login003.stockholm.se/...&verifyorder=${order}&_=${Date.now()}`; export const loginCookie = "https://login003.stockholm.se/..."; const urlLoggedIn = `https://etjanst.stockholm.se/vardnadshavare/inloggad2`; export const children = `${urlLoggedIn}/GetChildren`; export const calendar = (childId: string) => `${urlLoggedIn}/Calender/GetSchoolCalender?childId=${childId}&rowLimit=50`; export const classmates = (childId: string) => `${urlLoggedIn}/contacts/GetStudentsByClass?studentId=${childId}`; export const teachers = (childId: string, schoolForm: string) => `${urlLoggedIn}/contacts/GetTeachersByStudent?studentId=${childId}&schoolForm=${schoolForm}`; export const schoolContacts = (childId: string, schoolId: string) => `${urlLoggedIn}/contacts/GetSchoolContacts?schoolId=${schoolId}&studentId=${childId}&schoolForm=Klasslista`; export const user = "https://etjanst.stockholm.se/vardnadshavare/base/getuserdata"; export const news = (childId: string) => `${urlLoggedIn}/News/GetNewsArchive?bannerImageLimit=5000&childId=${childId}`; export const newsDetails = (childId: string, newsId: string) => `${urlLoggedIn}/News/GetNewsArticle?newsItemId=${newsId}&childId=${childId}`; export const image = (url: string) => `${urlLoggedIn}/NewsBanner?url=${url}`; export const notifications = (childId: string) => `${urlLoggedIn}/notifications/getnotifications?childId=${childId}`; export const menuRss = (childId: string) => `${urlLoggedIn}/Matsedel/GetMatsedelRSS?childId=${childId}`; export const menuList = (childId: string) => `${urlLoggedIn}/Matsedel/GetMatsedelList?childId=${childId}`; export const menuChoice = (childId: string) => `${urlLoggedIn}/Matsedel/GetMatsedelChoice?childId=${childId}`; export const schedule = (childId: string, fromDate: string, endDate: string) => `${urlLoggedIn}/Calender/GetSchema?childId=${childId}&startDate=${fromDate}&endDate=${endDate}`; export const cdn = "https://etjanst.stockholm.se/vardnadshavare/base/cdn"; export const auth = "https://etjanst.stockholm.se/vardnadshavare/base/auth"; export const startBundle = "https://etjanst.stockholm.se/vardnadshavare/bundles/start"; export const hemPage = "https://etjanst.stockholm.se/vardnadshavare/inloggad2/hem"; export const navigationControllerScript = "https://etjanst.stockholm.se/.../navigationController"; export const baseEtjanst = "https://etjanst.stockholm.se"; export const childcontrollerScript = `https://etjanst.stockholm.se/.../childcontroller?v=${Date.now()}`; export const createItemConfig = "https://raw.githubusercontent.com/kolplattformen/embedded-api/main/config.json"; // Skola24 export const ssoRequestUrl = (targetSystem: string) => `https://fnsservicesso1.stockholm.se/.../authenticate?customer=https://login001.stockholm.se&targetsystem=${targetSystem}`; export const ssoResponseUrl = "https://login001.stockholm.se/.../saml2sso"; export const samlResponseUrl = "https://fnsservicesso1.stockholm.se/.../response"; export const timetables = "https://fns.stockholm.se/.../timetables"; export const renderKey = "https://fns.stockholm.se/.../key"; export const timetable = "https://fns.stockholm.se/.../timetable"; export const topologyConfigUrl = "https://fantomenkrypto.vercel.app/.../getConfig"; export const selectChild = "https://etjanst.stockholm.se/.../SelectChild"; Routes - May 2022
  7. Before first blocking attempt… async getChildren(): Promise<Child[]> { const url

    = routes.children const response = await this.fetch('children', url, this.session) const data = await response.json() return parse.children(data) }
  8. First blocking attempt - 2 step auth token? public async

    getChildren(): Promise<Child[]> { const cdnUrl = await this.retrieveCdnUrl() const authBody = await this.retrieveAuthBody() const token = await this.retrieveAuthToken(cdnUrl, authBody) const url = routes.children const session = this.getRequestInit({ headers: { Accept: 'application/json;odata=verbose', Auth: token, Host: 'etjanst.stockholm.se', Referer: 'https://etjanst.stockholm.se/Vardnadshavare/inloggad2/hem', }, }) const response = await this.fetch('children', url, session) if (!response.ok) { throw new Error(`Server Error [${response.status}] [${response.statusText}] [${url}]`) } const data = await response.json() return parse.children(data) }
  9. First blocking attempt - 2 step auth token? private async

    retrieveCdnUrl(): Promise<string> { const url = routes.cdn const session = this.getRequestInit() const response = await this.fetch('cdn', url, session) const cdnUrl = await response.text() return cdnUrl } private async retrieveAuthBody(): Promise<string> { const url = routes.auth const session = this.getRequestInit() const response = await this.fetch('auth', url, session) const authBody = await response.text() return authBody }
  10. First blocking attempt - 2 step auth token? private async

    retrieveAuthToken(url: string, authBody: string): Promise<string> { const session = this.getRequestInit({ method: 'POST', headers: { Accept: 'text/plain', Origin: 'https://etjanst.stockholm.se', Referer: 'https://etjanst.stockholm.se/', Connection: 'keep-alive', }, body: authBody, }) delete session.headers['API-Key'] // Temporarily remove cookies const cookies = await this.cookieManager.getCookies(url) this.cookieManager.clearAll() // Perform request const response = await this.fetch('createItem', url, { ...session }) // Restore cookies cookies.forEach((cookie) => { this.cookieManager.setCookie(cookie, url) }) if (!response.ok) { throw new Error(`Server Error [${response.status}] [${response.statusText}] [${url}]`) } const authData = await response.json() return authData.token }
  11. private async retrieveXsrfToken(): Promise<void> { const url = routes.hemPage const

    session = this.getRequestInit() const response = await this.fetch('hemPage', url, session) const text = await response.text() const doc = html.parse(decode(text)) const xsrfToken = doc .querySelector('input[name="__RequestVerificationToken"]') ?.getAttribute('value') || '' this.addHeader('x-xsrf-token', xsrfToken) } Second blocking attempt - XSRF token
  12. Attempt 3 - 7: Renaming of the XSRF header {

    "headers": { "accept": "text/plain", "accept-language": "en-GB,en-SE;q=0.9,en;q=0.8,sv- SE;q=0.7,sv;q=0.6,en-US;q=0.5", "access-control-allow-origin": “*", ..., "x-xsrf-token": "SfONpuvKXD1XHML3Kelvm3easB6Xn3xtbVPG52jdpc3Q7sRxJv7_6wfjo 1qS3NOQWkfCvfPkJpJg0QIBmo358o7FdQY2aWvUOxA9MU2Fl0E1", "y-xsrf-token11": "FyXUbtZUE2iT09J7FOLTpfZ_onjbj3WEIO6jOY9B1KaZzMrAs4WS03AuW bQhmKyCEX2inTPVDzyPc58tN2EM4L1vYD6aH_zhlc7gVo9jaPdLKQc4qnE 6ue184cSamKE0" }, "referrer": "https://etjanst.stockholm.se/", "referrerPolicy": "strict-origin-when-cross-origin", "body": “XVDf/EliJ/oZH9BRlRCMNds2jCRcTL8/ isnpuj2wD6wH1lxX/cHY/…/ lOP858pPiVfc96M2jc0+yQEgnUBXPgQmFVC6CIHfQ0Mg==“, "method": "POST", "mode": "cors" }
  13. ootstrap','defaults','setItem','log','value','getItem','$locationProvider','autoRedirect','1228pNdmdk','Error','X-XSRF- oken','1611730aOSHwu','href','SchoolId','floor'];_0x376a=function(){return _0x273d7e;};return _0x376a();}var childPartialApp=angular['module']('childPartialApp' _0x16ddd2(0x18a),'angularUtils.directives.dirPagination']);childPartialApp[_0x16ddd2(0x1ac)]('ChildController',GetChildren),childPartialApp[_0x16ddd2(0x1c9)] [_0x16ddd2(0x192),function(_0x59f7b7){_0x59f7b7['html5Mode'](!![]);}]),childPartialApp[_0x16ddd2(0x1b2)](function(_0x1491ec){var 0x47a9a9=_0x16ddd2;_0x1491ec[_0x47a9a9(0x18d)]['headers']['common'][_0x47a9a9(0x196)]=angular['element'](_0x47a9a9(0x1a2))['attr'](_0x47a9a9(0x190));});function etChildren(_0x330acf,_0x474301,_0xa5d3c9,_0x2e9a78,_0x42dc61,_0x5973d3,_0x277a78){var 0x2c5996=_0x16ddd2;_0x330acf[_0x2c5996(0x1c8)]='',_0x330acf[_0x2c5996(0x19f)]={},_0x330acf[_0x2c5996(0x19f)][_0x2c5996(0x1b6)]=![],_0x330acf['error']

    _0x2c5996(0x1d2)]='',_0x330acf[_0x2c5996(0x1a7)],_0x330acf[_0x2c5996(0x1a3)]=![],_0x330acf[_0x2c5996(0x1ab)]=!![],_0x330acf[_0x2c5996(0x193)]=function(){var 0x4f3381=_0x2c5996;if(_0x5973d3[_0x4f3381(0x1a4)]()['studentId']!=null||_0x5973d3[_0x4f3381(0x1a4)]()[_0x4f3381(0x1b7)]!=null||_0x5973d3[_0x4f3381(0x1a4)]() _0x4f3381(0x1e4)]!=null)for(var _0x5274b0=0x0;_0x5274b0<_0x330acf['children'][_0x4f3381(0x1c3)];_0x5274b0++){if(_0x5973d3['search']()[_0x4f3381(0x1d3)]! null&&_0x5973d3[_0x4f3381(0x1a4)]()[_0x4f3381(0x1d3)][_0x4f3381(0x1c3)]>0x0){if(_0x330acf[_0x4f3381(0x19d)][_0x5274b0][_0x4f3381(0x1aa)]['toLowerCase'] )==_0x5973d3['search']()['studentId']['toLowerCase']()){if(_0x330acf[_0x4f3381(0x1ce)](_0x5973d3[_0x4f3381(0x1a4)]()[_0x4f3381(0x19b)])==!! ])_0x330acf[_0x4f3381(0x1b4)](_0x330acf[_0x4f3381(0x19d)][_0x5274b0]['Id'],_0x4f3381(0x1c7));else _0x330acf[_0x4f3381(0x1d0)](_0x5973d3[_0x4f3381(0x1a4)]() 'pushType'])==!![]?_0x330acf[_0x4f3381(0x1b4)](_0x330acf[_0x4f3381(0x19d)][_0x5274b0]['Id'],_0x4f3381(0x1db)+_0x5973d3[_0x4f3381(0x1a4)]() _0x4f3381(0x1cb)]):_0x330acf['changeChild'](_0x330acf[_0x4f3381(0x19d)][_0x5274b0]['Id'],'/vardnadshavare/inloggad2/Oversikt');}}else{if(_0x5973d3['search']() _0x4f3381(0x1b7)]!=null&&_0x5973d3[_0x4f3381(0x1a4)]()[_0x4f3381(0x1b7)][_0x4f3381(0x1c3)]>0x0){if(_0x330acf[_0x4f3381(0x19d)][_0x5274b0]['Classes']! null&&_0x330acf[_0x4f3381(0x19d)][_0x5274b0][_0x4f3381(0x1a1)][_0x4f3381(0x1cc)](_0x5973d3['search']()[_0x4f3381(0x1b7)])!=-0x1){if(_0x330acf['pushIsBookingType _0x5973d3[_0x4f3381(0x1a4)]()[_0x4f3381(0x19b)])==!![])_0x330acf[_0x4f3381(0x1b4)](_0x330acf[_0x4f3381(0x19d)][_0x5274b0]['Id'],_0x4f3381(0x1c7));else 0x330acf[_0x4f3381(0x1d0)](_0x5973d3[_0x4f3381(0x1a4)]()[_0x4f3381(0x19b)])==!![]?_0x330acf[_0x4f3381(0x1b4)](_0x330acf[_0x4f3381(0x19d)][_0x5274b0] 'Id'],_0x4f3381(0x1db)+_0x5973d3[_0x4f3381(0x1a4)]()[_0x4f3381(0x1cb)]):_0x330acf[_0x4f3381(0x1b4)](_0x330acf[_0x4f3381(0x19d)][_0x5274b0]['Id'],'/vardnadshavar nloggad2/Oversikt');}}else _0x5973d3[_0x4f3381(0x1a4)]()[_0x4f3381(0x1e4)]!=null&&_0x5973d3[_0x4f3381(0x1a4)]()[_0x4f3381(0x1e4)] 'length']>0x0&&(((_0x4f3381(0x1d4)+_0x330acf[_0x4f3381(0x19d)][_0x5274b0][_0x4f3381(0x199)])[_0x4f3381(0x1b0)]()==_0x5973d3[_0x4f3381(0x1a4)]()['schoolId'] _0x4f3381(0x1b0)]()||_0x5973d3[_0x4f3381(0x1a4)]()[_0x4f3381(0x1e4)]==_0x4f3381(0x1bb))&&(_0x330acf[_0x4f3381(0x1d0)](_0x5973d3['search']()['pushType'])==!![]? 0x330acf[_0x4f3381(0x1b4)](_0x330acf['children'][_0x5274b0]['Id'],_0x4f3381(0x1db)+_0x5973d3[_0x4f3381(0x1a4)]()[_0x4f3381(0x1cb)]):_0x330acf[_0x4f3381(0x1b4)] _0x330acf[_0x4f3381(0x19d)][_0x5274b0]['Id'],_0x4f3381(0x1b5))));}}else _0x330acf[_0x4f3381(0x19d)]!=null&&_0x330acf[_0x4f3381(0x19d)] 'length']==0x1&&location['pathname'][_0x4f3381(0x1cc)]('hem')>0x0&&_0x330acf[_0x4f3381(0x1b4)](_0x330acf[_0x4f3381(0x19d)][0x0] 'Id'],_0x4f3381(0x1b5));},_0x330acf['pushIsBookingType']=function(_0x17e4c2){var _0x3b6da3=_0x2c5996;return _0x17e4c2!=null&&(_0x17e4c2==_0x3b6da3(0x1d5)|| 0x17e4c2==_0x3b6da3(0x18b)||_0x17e4c2==_0x3b6da3(0x1c2)||_0x17e4c2==_0x3b6da3(0x1bf))?!![]:![];},_0x330acf['pushIsNewsType']=function(_0x334fe6){var 0x3f9fe8=_0x2c5996;return _0x334fe6!=null&&(_0x334fe6==_0x3f9fe8(0x1af)||_0x334fe6==_0x3f9fe8(0x1d6))?!![]:![];},_0x330acf['getChildren']=function(){var 0x3b4575=_0x2c5996;let _0x5d97bc=location[_0x3b4575(0x1e1)][_0x3b4575(0x1cc)](_0x3b4575(0x1b1))>0x0,_0x3918e9='';sessionStorage[_0x3b4575(0x191)](_0x3b4575(0x19 null&&sessionStorage['getItem'](_0x3b4575(0x1df))!=_0x3b4575(0x1e3)?(_0x330acf[_0x3b4575(0x1ab)]=![],_0x330acf[_0x3b4575(0x19d)]=JSON[_0x3b4575(0x1a5)] sessionStorage[_0x3b4575(0x191)](_0x3b4575(0x19d))),_0x330acf[_0x3b4575(0x1a3)]=sessionStorage['getItem'] _0x3b4575(0x1d7)),_0x330acf[_0x3b4575(0x1a7)]=_0x330acf[_0x3b4575(0x19d)][_0x3b4575(0x1c3)],sessionStorage[_0x3b4575(0x191)](_0x3b4575(0x1dd))! null&&(_0x330acf['selectedChild']=sessionStorage[_0x3b4575(0x191)]('currentChildName')),_0x330acf[_0x3b4575(0x193)]()):_0xa5d3c9['get'](_0x3b4575(0x1b8))['succe function(_0x2714a4){var _0x5bf9f4=_0x3b4575;_0x330acf[_0x5bf9f4(0x1ab)]=![],_0x2714a4[_0x5bf9f4(0x1ae)]==!![]?(_0x330acf['childrenCount']=_0x2714a4[_0x5bf9f4(0x 'length'],_0x2714a4['Data'][_0x5bf9f4(0x1c3)]>0x0?(_0x2714a4['Data'][_0x5bf9f4(0x1cd)](function(_0x430bef,_0x52a447,_0x422694){var 0x4c7eef=_0x5bf9f4;_0x430bef[_0x4c7eef(0x1d7)]&&(_0x330acf['errorSDSId']=!![],sessionStorage[_0x4c7eef(0x18e)]('isSameSDSId',!![]),_0x422694[_0x4c7eef(0x1ba)] _0x52a447,0x1));}),_0x330acf[_0x5bf9f4(0x19d)]=_0x2714a4[_0x5bf9f4(0x1a9)],sessionStorage['setItem'](_0x5bf9f4(0x19d),JSON[_0x5bf9f4(0x1be)] _0x2714a4[_0x5bf9f4(0x1a9)])),sessionStorage[_0x5bf9f4(0x191)]('currentChildName')!=null?_0x330acf[_0x5bf9f4(0x1c8)]=sessionStorage['getItem'](_0x5bf9f4(0x1dd)) 0x5d97bc&&(location['href']=_0x5bf9f4(0x1d9))):!_0x5d97bc&&(location[_0x5bf9f4(0x198)]=_0x5bf9f4(0x1d9)),_0x330acf[_0x5bf9f4(0x193)]()):(_0x330acf[_0x5bf9f4(0x1 _0x5bf9f4(0x1b6)]=!![],_0x330acf[_0x5bf9f4(0x19f)][_0x5bf9f4(0x1d2)]=_0x2714a4[_0x5bf9f4(0x195)]);})[_0x3b4575(0x19f)](function(_0x2e00bd){var 0x1a0b97=_0x3b4575;console[_0x1a0b97(0x18f)](_0x2e00bd),_0x330acf['error'][_0x1a0b97(0x1b6)]=!![];});},_0x330acf['changeChild']=function(_0x4402e3,_0xe56046){va 0x200d7c=_0x2c5996;_0x330acf[_0x200d7c(0x1ab)]=!![],_0xa5d3c9['post'](_0x200d7c(0x19c),{'id':_0x4402e3})[_0x200d7c(0x1b3)](function(_0x1fd322){var 0x461860=_0x200d7c;_0x1fd322['Success']==!![]?(sessionStorage[_0x461860(0x18e)](_0x461860(0x1de),_0x1fd322[_0x461860(0x1a9)]['Id']),sessionStorage['setItem'] _0x461860(0x1dd),_0x1fd322['Data'][_0x461860(0x1ad)]),sessionStorage['setItem'](_0x461860(0x1e2),_0x1fd322['Data']['SDSId']),localStorage[_0x461860(0x18e)] September 2021
  14. September 2021 - Obfuscation + client side key generation private

    getTopology(): string { var currentTime = new Date()['getTime'](); let topo = 'make talk identify inside rubber title fold physical clump member pond divide hood churn put brief swap ride paddle solve enjoy home sound basket|' + currentTime; let _0x9748 = 'hijklmnopqrstuvwxyz'; let _0x9731 = 9; for (let i = 0; i < _0x9731; i++) { topo = Buffer.from(topo).toString('base64') }; topo = topo['substring'](0, 1) + _0x9748['charAt'](_0x9731) + topo['substring'](1, topo['length']); return topo } Can’t be older than a few seconds from previous call!
  15. September 2021 - Fantomenkrypto export const fantomenkrypto = (instring, key)

    => { var state = key; var stringlen = instring.length; var outstr = []; for (var i = 0; i < stringlen; i++) { outstr[i] = instring.charAt(i) } for (var i = 0; i < stringlen; i++) { var p1 = state * (i + 67) + (state % 13043); var p2 = state * (i + 317) + (state % 48457); state = (p1 + p2) % 4639619; p1 %= stringlen; p2 %= stringlen; var tmp = outstr[p1]; outstr[p1] = outstr[p2]; outstr[p2] = tmp; }; return outstr.join('').split('%').join('\u007f').split('#1') 
 .join(‘%').split('#0').join('#').split('\u007f') // \u007f == DEL } export const findStrings = (strings) => { for(let i=0;i<strings.length;i++){ let str = strings[i]; if(str.slice(-1) == '|'){ return {topologyLongKey:strings[i], topologyShortKey:strings[i+1]} } } }
  16. Lessons learned • React Native • Low threshold for all

    - easily adopted from all backgrounds • Needs total clean of local workspace from time to time • Support for iOS is better than for Android • Performance isn’t great…
  17. Lessons learned • All APIs are open • If you

    annoy your users enough, they will try to fi x things themselves • Want people to work for free? Make it about their kids.