├── test ├── .gitignore ├── credentials-example.js ├── manualTest.js └── test.js ├── .vscode └── launch.json ├── package.json ├── .github └── workflows │ └── npmpublish.yml ├── README.md ├── .gitignore ├── DISECTION_PUT_LOCATION.md ├── DISECTION.md ├── DISECTION_CIRCLES.md └── index.js /test/.gitignore: -------------------------------------------------------------------------------- 1 | credentials.js 2 | -------------------------------------------------------------------------------- /test/credentials-example.js: -------------------------------------------------------------------------------- 1 | /* 2 | This file contains your login details for https://life360.com/ 3 | 4 | For the tests to run, you must give it your email and password. 5 | 6 | You may use a phone number instead by changing "email" to "phone" 7 | */ 8 | module.exports = { 9 | email: 'myUsername@example.com', 10 | password: 'MySecurePassword123!!!', 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Run manual test", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "program": "${workspaceFolder}/test/manualTest.js" 15 | }, 16 | { 17 | "type": "node", 18 | "request": "launch", 19 | "name": "Run library", 20 | "skipFiles": [ 21 | "/**" 22 | ], 23 | "program": "${workspaceFolder}/index.js" 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /test/manualTest.js: -------------------------------------------------------------------------------- 1 | const assert = require('chai').assert; 2 | const life360 = require('../index.js'); 3 | 4 | let credentials; 5 | try { 6 | credentials = require('./credentials.js'); 7 | } catch (e) { 8 | if (e.constructor === Error && e.code === 'MODULE_NOT_FOUND') { 9 | console.error('IMPORTANT'); 10 | console.error('Copy the "credentials-example.json" file and rename it to "credentials.json" before running any tests.'); 11 | console.error('Make sure you add your email or phone, and password to the credentials.js file!'); 12 | return; 13 | } else { 14 | throw e; 15 | } 16 | } 17 | 18 | (async () => { 19 | 20 | try { 21 | await life360.login(credentials); 22 | var circles = await life360.circles(); 23 | debugger; 24 | } catch (e) { 25 | debugger; 26 | } 27 | 28 | })(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "life360-node-api", 3 | "version": "0.0.10", 4 | "description": "An unofficial hook into Life360.com's API.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/kaylathedev/life360-node-api.git" 12 | }, 13 | "keywords": [ 14 | "life360", 15 | "tracking", 16 | "family" 17 | ], 18 | "author": "Kayla Colflesh", 19 | "license": "ISC", 20 | "bugs": { 21 | "url": "https://github.com/kaylathedev/life360-node-api/issues" 22 | }, 23 | "homepage": "https://github.com/kaylathedev/life360-node-api#readme", 24 | "dependencies": { 25 | "@types/node": "^14.14.2", 26 | "https": "^1.0.0", 27 | "querystring": "^0.2.0" 28 | }, 29 | "devDependencies": { 30 | "chai": "^4.2.0", 31 | "mocha": "^10.2.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const assert = require('chai').assert; 2 | const life360 = require('../index.js'); 3 | 4 | let credentials; 5 | try { 6 | credentials = require('./credentials.js'); 7 | } catch (e) { 8 | if (e.constructor === Error && e.code === 'MODULE_NOT_FOUND') { 9 | console.error('IMPORTANT'); 10 | console.error('Copy the "credentials-example.json" file and rename it to "credentials.json" before running any tests.'); 11 | console.error('Make sure you add your email or phone, and password to the credentials.js file!'); 12 | return; 13 | } else { 14 | throw e; 15 | } 16 | } 17 | 18 | describe('Life360', () => { 19 | 20 | describe('login with user/pass object', () => { 21 | it('should login successfully', () => { 22 | var login = await life360.login(credentials); 23 | console.log(login); 24 | return true; 25 | }) 26 | }); 27 | 28 | }); 29 | -------------------------------------------------------------------------------- /.github/workflows/npmpublish.yml: -------------------------------------------------------------------------------- 1 | name: Node.js Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 12 15 | - run: npm ci 16 | - run: npm test 17 | 18 | publish-npm: 19 | needs: build 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v1 23 | - uses: actions/setup-node@v1 24 | with: 25 | node-version: 12 26 | registry-url: https://registry.npmjs.org/ 27 | - run: npm ci 28 | - run: npm publish 29 | env: 30 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 31 | 32 | publish-gpr: 33 | needs: build 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v1 37 | - uses: actions/setup-node@v1 38 | with: 39 | node-version: 12 40 | registry-url: https://npm.pkg.github.com/ 41 | scope: '@your-github-username' 42 | - run: npm ci 43 | - run: npm publish 44 | env: 45 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # life360-node-api 2 | 3 | An unofficial implementation of Life360's API in Node.js. 4 | 5 | I am in the progress of dissecting the HTTP api behind Life360.com. This library allows you to login, see information about your family and circles, and locate your family members. 6 | 7 | ## Disecting the API 8 | 9 | If you are interested in how I'm understanding Life360.com's HTTP api, go to this page at [DISECTION.md](DISECTION.md). 10 | 11 | ## Install 12 | 13 | ```console 14 | $ npm install life360-node-api 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```js 20 | const life360 = require('life360-node-api') 21 | ``` 22 | 23 | Every function that talks to Life360.com will be asynchronous. 24 | 25 | Login with your username and password. Save the new client to a variable (this holds your authentication tokens). 26 | 27 | ```js 28 | client = await life360.login('myusername', 'mySecurePassword123') 29 | ``` 30 | 31 | Get a list of your circles and log the names of each of them 32 | 33 | ```js 34 | let circles = await client.listCircles() 35 | 36 | for (const circle of circles) { 37 | console.log(circle.name) 38 | } 39 | ``` 40 | 41 | Get a list of your circle's members 42 | 43 | ```js 44 | let myCircle = circles[0] 45 | // alternatively, use the circles.findByName to search for your circle by name. 46 | // let myCircle = circles.findByName('family') 47 | let members = await myCircle.listMembers() 48 | 49 | for (const member of members) { 50 | console.log(`${member.firstName} ${member.lastName}`) 51 | console.log(`${member.location.latitude}, ${member.location.longitude}`) 52 | } 53 | ``` 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and not Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | # Stores VSCode versions used for testing VSCode extensions 108 | .vscode-test -------------------------------------------------------------------------------- /DISECTION_PUT_LOCATION.md: -------------------------------------------------------------------------------- 1 | # Disection of the Life360.com undocumented API - Put Location 2 | 3 | [Go back](DISECTION.md) 4 | 5 | Android devices running Life360 update the location using the following request. 6 | 7 | ```text 8 | PUT /v4/locations HTTP/1.1 9 | X-UserContext: eyJnZW9sb2NhdGlvbiI6eyJsYXQiOiIwLjAiLCJsb24iOiIwLjAiLCJhbHQiOiIwLjAiLCJhY2N1cmFjeSI6IjEwLjAwIiwiaGVhZGluZyI6IjAuMCIsInNwZWVkIjoiMC4wIiwidGltZXN0YW1wIjoiMTYwMzE1MTQwMCIsImFnZSI6IjEifSwiZ2VvbG9jYXRpb25fbWV0YSI6eyJ3c3NpZCI6IjAxOjIzOjQ1OjY3Ojg5OmFiIiwicmVxc3NpZCI6IlwiV2lmaSBTU0lEXCIiLCJsbW9kZSI6ImZvcmUifSwiZGV2aWNlIjp7ImJhdHRlcnkiOiI5OSIsImNoYXJnZSI6IjEiLCJ3aWZpX3N0YXRlIjoiMSIsImJ1aWxkIjoiMjI4OTgwIiwiZHJpdmVTREtTdGF0dXMiOiJPRkYiLCJ1c2VyQWN0aXZpdHkiOiJ1bmtub3duIn19 10 | Accept: application/json 11 | Accept-Language: en_US 12 | User-Agent: com.life360.android.safetymapd/KOKO/20.6.0 android/9 13 | X-Device-ID: 0123456789abcdef 14 | Authorization: Bearer 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789AB 15 | Content-Type: application/x-www-form-urlencoded 16 | Content-Length: 0 17 | Host: android.life360.com 18 | Connection: Keep-Alive 19 | Accept-Encoding: gzip 20 | ``` 21 | 22 | A successful response is a 200 with no body. I have removed headers, modified and resent the above request many times. I found that all you need to update your location is this. 23 | 24 | ```text 25 | PUT /v4/locations HTTP/1.1 26 | X-UserContext: eyJnZW9sb2NhdGlvbiI6eyJsYXQiOiIwLjAiLCJsb24iOiIwLjAiLCJhbHQiOiIwLjAiLCJhY2N1cmFjeSI6IjEwLjAwIiwiaGVhZGluZyI6IjAuMCIsInNwZWVkIjoiMC4wIiwidGltZXN0YW1wIjoiMTYwMzE1MTQwMCIsImFnZSI6IjEifSwiZ2VvbG9jYXRpb25fbWV0YSI6eyJ3c3NpZCI6IjAxOjIzOjQ1OjY3Ojg5OmFiIiwicmVxc3NpZCI6IlwiV2lmaSBTU0lEXCIiLCJsbW9kZSI6ImZvcmUifSwiZGV2aWNlIjp7ImJhdHRlcnkiOiI5OSIsImNoYXJnZSI6IjEiLCJ3aWZpX3N0YXRlIjoiMSIsImJ1aWxkIjoiMjI4OTgwIiwiZHJpdmVTREtTdGF0dXMiOiJPRkYiLCJ1c2VyQWN0aXZpdHkiOiJ1bmtub3duIn19 27 | X-Device-ID: 0123456789abcdef 28 | Authorization: Bearer 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789AB 29 | Host: android.life360.com 30 | ``` 31 | 32 | If you are using a library to make HTTP requests, you only need to specifiy the following information. 33 | 34 | **Host:** android.life360.com 35 | 36 | **Path:** /v4/locations 37 | 38 | **HTTP Method:** PUT 39 | 40 | **Headers** 41 | 42 | * `X-UserContext` 43 | * `X-Device-ID` 44 | * `Authorization` 45 | 46 | ## Analysis 47 | 48 | `Authorization` contains the authentication token which is given upon logging in. 49 | 50 | --- 51 | 52 | `X-UserContext` is a base64 encoded, serialized JSON object which resembles something like this. 53 | 54 | ```json 55 | { 56 | "geolocation": { 57 | "lat": "0.0", 58 | "lon": "0.0", 59 | "alt": "0.0", 60 | "accuracy": "10.00", 61 | "heading": "0.0", 62 | "speed": "0.0", 63 | "timestamp": "1603151400", 64 | "age": "1" 65 | }, 66 | "geolocation_meta": { 67 | "wssid": "01:23:45:67:89:ab", 68 | "reqssid": "\"Wifi SSID\"", 69 | "lmode": "fore" 70 | }, 71 | "device": { 72 | "battery": "99", 73 | "charge": "1", 74 | "wifi_state": "1", 75 | "build": "228980", 76 | "driveSDKStatus": "OFF", 77 | "userActivity":"unknown" 78 | } 79 | } 80 | ``` 81 | 82 | --- 83 | 84 | `X-Device-ID` appears to be a 64 bit hexadecimal string. It is required upon making the PUT request, and Life360 will not update your location if you provide the wrong device id. I attempted to determine how this is created/retrieved. By using HttpCanary (an Android packet capturer), it looks like the app either generates this id, or it retrieves it from the Android system itself. 85 | 86 | I have not determined the original source of this id. 87 | -------------------------------------------------------------------------------- /DISECTION.md: -------------------------------------------------------------------------------- 1 | # Disection of the Life360.com undocumented API 2 | 3 | [Go back](README.md) 4 | 5 | * [Disecting Circles](DISECTION_CIRCLES.md) 6 | * [Disecting "Put Location"](DISECTION_PUT_LOCATION.md) 7 | 8 | ## Setup 9 | 10 | Using Debian 10.2 with a bash terminal. Installed curl. 11 | 12 | Base url is `https://www.life360.com/` 13 | 14 | All requests include the header `Accept: application/json` 15 | 16 | Expect each response to be a `200 OK` 17 | 18 | Any personally identifying information in the example responses will be omitted. 19 | 20 | ## Initalize variables 21 | 22 | ```bash 23 | USER=johndoe@example.com 24 | PASS=mySecure5Password45t 25 | ``` 26 | 27 | ## Login @ `/v3/oauth2/token` 28 | 29 | ```bash 30 | curl -H "Accept: application/json" -H "Authorization: Basic U3dlcUFOQWdFVkVoVWt1cGVjcmVrYXN0ZXFhVGVXckFTV2E1dXN3MzpXMnZBV3JlY2hhUHJlZGFoVVJhZ1VYYWZyQW5hbWVqdQ==" https://www.life360.com/v3/oauth2/token --data "username=$USER&password=$PASS&grant_type=password" 31 | ``` 32 | 33 | Response 34 | ```json 35 | { 36 | "access_token": "rAnDoMChArAcTeRsInToKeN123456789AcEsSToKeNString", 37 | "token_type": "Bearer", 38 | "onboarding": 0, 39 | "user": { 40 | "id": "abcdefgh-ijkl-mnop-qrst-uvwxyz012345", 41 | "firstName": "John", 42 | "lastName": "Doe", 43 | "loginEmail": "johndoe@example.com", 44 | "loginPhone": "+15555550123", 45 | "avatar": "/img/user_images/abcdefgh-ijkl-mnop-qrst-uvwxyz012345/abcdefgh-ijkl-mnop-qrst-uvwxyz012345.png?fd=2", 46 | "locale": "en_US", 47 | "language": "en", 48 | "created": "2019-01-02 03:45:56", 49 | "settings": { 50 | "map": { 51 | "police": "1", 52 | "fire": "1", 53 | "hospital": "1", 54 | "sexOffenders": "1", 55 | "crime": "1", 56 | "crimeDuration": "a", 57 | "family": "1", 58 | "advisor": "1", 59 | "placeRadius": "1", 60 | "memberRadius": "1" 61 | }, 62 | "alerts": { "crime": "1", "sound": "1" }, 63 | "zendrive": { "sdk_enabled": "OFF" }, 64 | "locale": "en_US", 65 | "unitOfMeasure": "i", 66 | "dateFormat": "mdy12", 67 | "timeZone": "America/New_York" 68 | }, 69 | "communications":[ 70 | { "channel": "Voice", "value": "+15555550123", "type": "Home" }, 71 | { "channel": "Email", "value": "johndoe@example.com", "type": "" } 72 | ], 73 | "cobranding": [] 74 | }, 75 | "cobranding": [], 76 | "promotions": [], 77 | "state": null 78 | } 79 | ``` 80 | 81 | **Set token acquired from logging in** 82 | 83 | ```bash 84 | TOKEN=rAnDoMChArAcTeRsInToKeN123456789AcEsSToKeNString 85 | ``` 86 | 87 | ## List Circles @ `/v3/circles` 88 | 89 | ```bash 90 | curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" https://www.life360.com/v3/circles 91 | ``` 92 | 93 | Response 94 | ```json 95 | { 96 | "circles": [ 97 | { 98 | "id": "abcdefgh-ijkl-mnop-qrst-uvwxyz012345", 99 | "name": "Family", 100 | "color": "f000f0", 101 | "type": "basic", 102 | "createdAt": "1451140007", 103 | "memberCount": "3", 104 | "unreadMessages": "0", 105 | "unreadNotifications": "0", 106 | "features": { 107 | "ownerId": null, 108 | "skuId": null, 109 | "premium": "0", 110 | "locationUpdatesLeft": 0, 111 | "priceMonth": "0", 112 | "priceYear": "0", 113 | "skuTier": null 114 | } 115 | } 116 | ] 117 | } 118 | ``` 119 | 120 | Optionally, set the `CIRCLE_ID` variable to your circle id. 121 | 122 | ```bash 123 | CIRCLE_ID=abcdefgh-ijkl-mnop-qrst-uvwxyz012345 124 | ``` 125 | 126 | ### [Click here to view more information about circles](DISECTION_CIRCLES.md) 127 | 128 | ## Information about yourself 129 | 130 | ```bash 131 | curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" https://www.life360.com/v3/users/me 132 | ``` 133 | 134 | Response 135 | ```json 136 | { 137 | "id": "abcdefgh-ijkl-mnop-qrst-uvwxyz012345", 138 | "firstName": "John", 139 | "lastName": "Doe", 140 | "loginEmail": "johndoe@example.com", 141 | "loginPhone": "+15555550123", 142 | "avatar": "\/img\/user_images\/abcdefgh-ijkl-mnop-qrst-uvwxyz012345\/abcdefgh-ijkl-mnop-qrst-uvwxyz012345.png?fd=2", 143 | "locale": "en_US", 144 | "language": "en", 145 | "created": "2019-01-02 03:45:56", 146 | "settings": { 147 | "map": { 148 | "police": "1", 149 | "fire": "1", 150 | "hospital": "1", 151 | "sexOffenders": "1", 152 | "crime": "1", 153 | "crimeDuration": "a", 154 | "family": "1", 155 | "advisor": "1", 156 | "placeRadius": "1", 157 | "memberRadius": "1" 158 | }, 159 | "alerts": { "crime": "1", "sound": "1" }, 160 | "zendrive": { "sdk_enabled": "OFF" }, 161 | "locale": "en_US", 162 | "unitOfMeasure": "i", 163 | "dateFormat": "mdy12", 164 | "timeZone": "America/New_York" 165 | }, 166 | "communications": [ 167 | { "channel": "Voice", "value": "+15555550123", "type": "Home" }, 168 | { "channel": "Email", "value": "johndoe@example.com", "type": "" } 169 | ], 170 | "cobranding": [] 171 | } 172 | ``` 173 | 174 | ## Crimes 175 | 176 | Get crimes from November 1st to December 1st with a bounding box around downtown York PA. 177 | 178 | See [http://bboxfinder.com/](http://bboxfinder.com/#39.95,-76.73,39.96,-76.72) for the bounding box. 179 | 180 | ```bash 181 | curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" "https://www.life360.com/v3/crimes?boundingBox[bottomRightLatitude]=39.94&boundingBox[bottomRightLongitude]=-76.72&boundingBox[topLeftLatitude]=39.95&boundingBox[topLeftLongitude]=-76.73" 182 | ``` 183 | 184 | Response *(only 2 crimes shown here for sake of brevity)* 185 | ```json 186 | { 187 | "crimes": [ 188 | { 189 | "id": "134776059", 190 | "incident_date": null, 191 | "type": "Theft", 192 | "address": "8XX S NEWBERRY ST, YORK, PA", 193 | "latitude": "39.949431733867", 194 | "longitude": "-76.729124090221", 195 | "source": "http://spotcrime.com/mobile/crime/?134776059-4f63aa20be59b112e52345d337225940", 196 | "description": "A Theft Report", 197 | "incidentDate": "1575265800" 198 | }, 199 | { 200 | "id": "121711154", 201 | "incident_date": null, 202 | "type": "Vandalism", 203 | "address": "10XX S GEORGE ST, YORK, PA", 204 | "latitude": "39.946233", 205 | "longitude": "-76.720702", 206 | "source": "http://spotcrime.com/mobile/crime/?121711154-3f84341191380addcb98c0eb9245ca6c", 207 | "description": "Criminal Mischief.", 208 | "incidentDate": "1551192600" 209 | }, 210 | ... 211 | ] 212 | } 213 | ``` 214 | -------------------------------------------------------------------------------- /DISECTION_CIRCLES.md: -------------------------------------------------------------------------------- 1 | # Disection of the Life360.com undocumented API - Circles 2 | 3 | [Go back](DISECTION.md) 4 | 5 | Make sure your CIRCLE variable is set to the circle id you want to inspect. 6 | 7 | ```bash 8 | CIRCLE_ID=abcdefgh-ijkl-mnop-qrst-uvwxyz012345 9 | ``` 10 | 11 | 12 | ## Get circle info @ `/v3/circles/$CIRCLE_ID` 13 | 14 | ```bash 15 | curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" https://www.life360.com/v3/circles/$CIRCLE_ID 16 | ``` 17 | 18 | Response 19 | ```json 20 | { 21 | } 22 | ``` 23 | 24 | 25 | ## ???code??? @ `/v3/circles/$CIRCLE_ID/code` 26 | 27 | ```bash 28 | curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" https://www.life360.com/v3/circles/$CIRCLE_ID/code 29 | ``` 30 | 31 | Response 32 | ```json 33 | { 34 | } 35 | ``` 36 | 37 | 38 | ## List members @ `/v3/circles/$CIRCLE_ID/members` 39 | 40 | ```bash 41 | curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" https://www.life360.com/v3/circles/$CIRCLE_ID/members 42 | ``` 43 | 44 | Response 45 | ```json 46 | { 47 | } 48 | ``` 49 | 50 | 51 | ## Get emergency contacts @ `/v3/circles/$CIRCLE_ID/emergencyContacts` 52 | 53 | ### Needs more disection!!! 54 | 55 | ```bash 56 | curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" https://www.life360.com/v3/circles/$CIRCLE_ID/emergencyContacts 57 | ``` 58 | 59 | Response 60 | ```json 61 | { 62 | "emergencyContacts": [] 63 | } 64 | ``` 65 | 66 | 67 | ## List messages @ `/v3/circles/$CIRCLE_ID/messages` 68 | 69 | Optional GET argument: `count=INTEGER` 70 | 71 | ```bash 72 | curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" https://www.life360.com/v3/circles/$CIRCLE_ID/messages 73 | ``` 74 | 75 | Response 76 | ```json 77 | { 78 | "messages": [ 79 | { 80 | "id": "01234567-8912-3456-7890-abcdefgh1111", 81 | "userId": "01234567-8912-3456-7890-abcdefgh1111", 82 | "toUserId": [ 83 | "01234567-8912-3456-7890-abcdefgh1111" 84 | ], 85 | "text": "Test. Ignore this.", 86 | "title": "Me to Dad", 87 | "type": "message", 88 | "channel": "fc", 89 | "timestamp": 1570900000, 90 | "location": null, 91 | "clientMessageId": null, 92 | "interfaces": [], 93 | "photo": null 94 | }, 95 | { 96 | "id": "01234567-8912-3456-7890-abcdefgh1111", 97 | "userId": "01234567-8912-3456-7890-abcdefgh1111", 98 | "toUserId": [ 99 | "01234567-8912-3456-7890-abcdefgh1111" 100 | ], 101 | "text": "❤ that you are at Home.", 102 | "title": "Dad to Me", 103 | "type": "message", 104 | "channel": "fc", 105 | "timestamp": 1528240000, 106 | "location": null, 107 | "clientMessageId": null, 108 | "interfaces": [], 109 | "photo": null 110 | }, 111 | { 112 | "id": "01234567-8912-3456-7890-abcdefgh1111", 113 | "userId": "01234567-8912-3456-7890-abcdefgh1111", 114 | "toUserId": [ 115 | "01234567-8912-3456-7890-abcdefgh1111", 116 | "01234567-8912-3456-7890-abcdefgh1111" 117 | ], 118 | "text": "Checked in @ Home", 119 | "title": "Dad to Me, Mom", 120 | "type": "message", 121 | "channel": "fc", 122 | "timestamp": 1528240000, 123 | "location": { 124 | "latitude": "39.8", 125 | "longitude": "-76.7", 126 | "accuracy": "0", 127 | "startTimestamp": null, 128 | "endTimestamp": 1528240000, 129 | "since": null, 130 | "timestamp": 1528240000, 131 | "name": null, 132 | "placeType": null, 133 | "source": null, 134 | "sourceId": null, 135 | "address1": "9 Dummy st", 136 | "address2": "York, PA", 137 | "shortAddress": null, 138 | "inTransit": "0", 139 | "tripId": null, 140 | "driveSDKStatus": null, 141 | "battery": null, 142 | "charge": "0", 143 | "wifiState": null, 144 | "speed": 0, 145 | "isDriving": "0", 146 | "userActivity": null 147 | }, 148 | "clientMessageId": null, 149 | "interfaces": [], 150 | "photo": null 151 | }, 152 | { 153 | "id": "01234567-8912-3456-7890-abcdefgh1111", 154 | "userId": "01234567-8912-3456-7890-abcdefgh1111", 155 | "toUserId": [ 156 | "01234567-8912-3456-7890-abcdefgh1111" 157 | ], 158 | "text": "Yippee", 159 | "title": "Chris to Me", 160 | "type": "message", 161 | "channel": "fc", 162 | "timestamp": 1451140000, 163 | "location": { 164 | "latitude": "38.3", 165 | "longitude": "-75.1", 166 | "accuracy": "8.3", 167 | "startTimestamp": null, 168 | "endTimestamp": 1451140000, 169 | "since": null, 170 | "timestamp": 1451140000, 171 | "name": null, 172 | "placeType": null, 173 | "source": null, 174 | "sourceId": null, 175 | "address1": "Something Rd", 176 | "address2": "Baltimore, MD", 177 | "shortAddress": null, 178 | "inTransit": "0", 179 | "tripId": null, 180 | "driveSDKStatus": null, 181 | "battery": null, 182 | "charge": "0", 183 | "wifiState": null, 184 | "speed": 0, 185 | "isDriving": "0", 186 | "userActivity": null 187 | }, 188 | "clientMessageId": null, 189 | "interfaces": [], 190 | "photo": null 191 | } 192 | ], 193 | "names": { 194 | "01234567-8912-3456-7890-abcdefgh1111": { 195 | "name": "Dads Name", 196 | "status": "A" 197 | }, 198 | "01234567-8912-3456-7890-abcdefgh2222": { 199 | "name": "Moms Name", 200 | "status": "A" 201 | }, 202 | "01234567-8912-3456-7890-abcdefgh3333": { 203 | "name": "Kids Name", 204 | "status": "A" 205 | } 206 | } 207 | } 208 | ``` 209 | 210 | 211 | ## Get member alerts @ `/v3/circles/$CIRCLE_ID/member/alerts` 212 | 213 | ```bash 214 | curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" https://www.life360.com/v3/circles/$CIRCLE_ID/member/alerts 215 | ``` 216 | 217 | Response 218 | ```json 219 | { 220 | "alerts": [ 221 | { 222 | "memberId": "01234567-8912-3456-7890-abcdefgh1111", 223 | "lowBattery": true 224 | }, 225 | { 226 | "memberId": "01234567-8912-3456-7890-abcdefgh2222", 227 | "lowBattery": true 228 | } 229 | ] 230 | } 231 | 232 | ``` 233 | 234 | 235 | ## Get members history @ `/v3/circles/$CIRCLE_ID/members/history` 236 | 237 | Optional GET argument: `since=UNIX_TIMESTAMP_SECONDS` 238 | 239 | ```bash 240 | curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" https://www.life360.com/v3/circles/$CIRCLE_ID/members/history 241 | ``` 242 | 243 | Response 244 | ```json 245 | { 246 | "locations": [ 247 | { 248 | "latitude": "39.9", 249 | "longitude": "-76.6", 250 | "accuracy": "50", 251 | "startTimestamp": "1576000000", 252 | "endTimestamp": "1577000000", 253 | "since": "1576123000", 254 | "timestamp": "1576128000", 255 | "name": "High Paying Job", 256 | "placeType": null, 257 | "source": false, 258 | "sourceId": null, 259 | "address1": "High Paying Job", 260 | "address2": "", 261 | "shortAddress": "", 262 | "inTransit": "0", 263 | "tripId": null, 264 | "driveSDKStatus": null, 265 | "battery": "91", 266 | "charge": "0", 267 | "wifiState": "0", 268 | "speed": 0, 269 | "isDriving": "0", 270 | "userActivity": "unknown", 271 | "userId": "01234567-8912-3456-7890-abcdefghijkl" 272 | }, 273 | { 274 | "latitude": "39.8", 275 | "longitude": "-76.7", 276 | "accuracy": "50", 277 | "startTimestamp": "1576000000", 278 | "endTimestamp": "1577000000", 279 | "since": "1576123000", 280 | "timestamp": "1576128000", 281 | "name": "Home", 282 | "placeType": null, 283 | "source": false, 284 | "sourceId": null, 285 | "address1": "Home", 286 | "address2": "", 287 | "shortAddress": "", 288 | "inTransit": "0", 289 | "tripId": null, 290 | "driveSDKStatus": null, 291 | "battery": "57", 292 | "charge": "0", 293 | "wifiState": "0", 294 | "speed": 0.0041862773, 295 | "isDriving": "0", 296 | "userActivity": "unknown", 297 | "userId": "01234567-8912-3456-7890-abcdefghijkl" 298 | } 299 | ] 300 | } 301 | ``` 302 | 303 | 304 | ## Get preferences @ `/v3/circles/$CIRCLE_ID/member/preferences` 305 | 306 | ```bash 307 | curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" https://www.life360.com/v3/circles/$CIRCLE_ID/member/preferences 308 | ``` 309 | 310 | Response 311 | ```json 312 | { 313 | "email": "0", 314 | "sms": "0", 315 | "push": "1", 316 | "shareLocation": "1" 317 | } 318 | ``` 319 | 320 | 321 | ## List all places @ `/v3/circles/$CIRCLE_ID/allplaces` 322 | 323 | ```bash 324 | curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" https://www.life360.com/v3/circles/$CIRCLE_ID/allplaces 325 | ``` 326 | 327 | Response 328 | ```json 329 | { 330 | "places": [ 331 | { 332 | "id": "01234567-8912-3456-7890-abcdefghijkl", 333 | "source": "l", 334 | "source_id": "01234567-8912-3456-7890-abcdefghijkl", 335 | "owner_id": "01234567-8912-3456-7890-abcdefghijkl", 336 | "name": "Semper Fitness", 337 | "latitude": 39.9, 338 | "longitude": -76.7, 339 | "radius": 70.5, 340 | "address": null, 341 | "circle_id": "01234567-8912-3456-7890-abcdefghijkl", 342 | "hasAlerts": 0 343 | }, 344 | { 345 | "id": "01234567-8912-3456-7890-abcdefghijkl", 346 | "source": "l", 347 | "source_id": "01234567-8912-3456-7890-abcdefghijkl", 348 | "owner_id": "01234567-8912-3456-7890-abcdefghijkl", 349 | "name": "Home", 350 | "latitude": 39.8, 351 | "longitude": -76.7, 352 | "radius": 150.5, 353 | "address": null, 354 | "circle_id": "01234567-8912-3456-7890-abcdefghijkl", 355 | "hasAlerts": 0 356 | }, 357 | { 358 | "id": "01234567-8912-3456-7890-abcdefghijkl", 359 | "source": "l", 360 | "source_id": "01234567-8912-3456-7890-abcdefghijkl", 361 | "owner_id": "01234567-8912-3456-7890-abcdefghijkl", 362 | "name": "York City Work", 363 | "latitude": 39.8, 364 | "longitude": -76.8, 365 | "radius": 150.5, 366 | "address": null, 367 | "circle_id": "01234567-8912-3456-7890-abcdefghijkl", 368 | "hasAlerts": 0 369 | }, 370 | { 371 | "id": "01234567-8912-3456-7890-abcdefghijkl", 372 | "source": "l", 373 | "source_id": "01234567-8912-3456-7890-abcdefghijkl", 374 | "owner_id": "01234567-8912-3456-7890-abcdefghijkl", 375 | "name": "Minimum Wage Job", 376 | "latitude": 39.9, 377 | "longitude": -76.6, 378 | "radius": 0, 379 | "address": null, 380 | "circle_id": "01234567-8912-3456-7890-abcdefghijkl", 381 | "hasAlerts": 0 382 | }, 383 | { 384 | "id": "01234567-8912-3456-7890-abcdefghijkl", 385 | "source": "l", 386 | "source_id": "01234567-8912-3456-7890-abcdefghijkl", 387 | "owner_id": "01234567-8912-3456-7890-abcdefghijkl", 388 | "name": "High Paying Job", 389 | "latitude": 39.9, 390 | "longitude": -76.6, 391 | "radius": 300.5, 392 | "address": null, 393 | "circle_id": "01234567-8912-3456-7890-abcdefghijkl", 394 | "hasAlerts": 0 395 | } 396 | ] 397 | } 398 | ``` 399 | 400 | 401 | ## List places @ `/v3/circles/$CIRCLE_ID/places` 402 | 403 | ```bash 404 | curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" https://www.life360.com/v3/circles/$CIRCLE_ID/places 405 | ``` 406 | 407 | Response 408 | ```json 409 | { 410 | "places": [ 411 | { 412 | "id": "01234567-8912-3456-7890-abcdefghijkl", 413 | "ownerId": "01234567-8912-3456-7890-abcdefghijkl", 414 | "circleId": "01234567-8912-3456-7890-abcdefghijkl", 415 | "name": "Home", 416 | "latitude": "39.1234", 417 | "longitude": "-76.1234", 418 | "radius": "150.5", 419 | "type": 1, 420 | "typeLabel": "Other" 421 | }, 422 | { 423 | "id": "01234567-8912-3456-7890-abcdefghijkl", 424 | "ownerId": "01234567-8912-3456-7890-abcdefghijkl", 425 | "circleId": "01234567-8912-3456-7890-abcdefghijkl", 426 | "name": "High Paying Job", 427 | "latitude": "39.1234", 428 | "longitude": "-76.1234", 429 | "radius": "150.5", 430 | "type": null, 431 | "typeLabel": null 432 | }, 433 | { 434 | "id": "01234567-8912-3456-7890-abcdefghijkl", 435 | "ownerId": "01234567-8912-3456-7890-abcdefghijkl", 436 | "circleId": "01234567-8912-3456-7890-abcdefghijkl", 437 | "name": "Minimum Wage Job", 438 | "latitude": "39.1234", 439 | "longitude": "-76.1234", 440 | "radius": "0.0", 441 | "type": null, 442 | "typeLabel": null 443 | }, 444 | { 445 | "id": "01234567-8912-3456-7890-abcdefghijkl", 446 | "ownerId": "01234567-8912-3456-7890-abcdefghijkl", 447 | "circleId": "01234567-8912-3456-7890-abcdefghijkl", 448 | "name": "York City Work", 449 | "latitude": "39.1234", 450 | "longitude": "-76.1234", 451 | "radius": "300.5", 452 | "type": 3, 453 | "typeLabel": "Work" 454 | } 455 | ] 456 | } 457 | ``` 458 | 459 | 460 | ## List nearby places @ `/v3/circles/$CIRCLE_ID/nearbyplaces/$LAT/$LON` 461 | 462 | Make sure `LAT` and `LON` variables are set before running the curl command. 463 | 464 | Optional GET argument: `wifiscan=STRING` 465 | 466 | ```bash 467 | curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" https://www.life360.com/v3/circles/$CIRCLE_ID/nearbyplaces/$LAT/$LON 468 | ``` 469 | 470 | Response: Same as listing all places, but near a latitude and longitude. 471 | 472 | 473 | ## Get watch list @ `/v3/circles/$CIRCLE_ID/driverbehavior/watchlist` 474 | 475 | ### Needs more disection!!! 476 | 477 | ```bash 478 | curl -H "Accept: application/json" -H "Authorization: Bearer $TOKEN" https://www.life360.com/v3/circles/$CIRCLE_ID/driverbehavior/watchlist 479 | ``` 480 | 481 | Response 482 | ```json 483 | { 484 | "watchlist": [], 485 | "sdkEnabled": { 486 | "12345678-abcd-efgh-ijkl-mnopqrstuvwx": "OFF", 487 | "87654321-dcba-hgfe-lkji-xwvutsrqponm": "OFF" 488 | } 489 | } 490 | ``` 491 | 492 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const querystring = require('querystring'); 3 | 4 | let DEBUG_FLAG = false; 5 | 6 | 7 | /** 8 | * Trys to create a float from a variable of unknown type. 9 | * 10 | * @param {any} x Unknown variable that might contain a float. 11 | */ 12 | function tryCreateFloat(x) { 13 | if (typeof x === 'string') { 14 | const floatRegex = /^-?\d+(?:[.,]\d*?)?$/; 15 | if (floatRegex.test(x)) { 16 | const result = parseFloat(x); 17 | if (!isNaN(result)) return result; 18 | } 19 | } 20 | return x; 21 | } 22 | 23 | /** 24 | * Trys to create an integer from a variable of unknown type. 25 | * 26 | * @param {any} x Unknown variable that might contain an integer. 27 | */ 28 | function tryCreateInt(x) { 29 | if (typeof x === 'string') { 30 | if ([...x].every(c => '0123456789'.includes(c))) { 31 | x = parseInt(x); 32 | } 33 | } 34 | return x; 35 | } 36 | 37 | /** 38 | * Trys to create a boolean from a variable of unknown type. 39 | * 40 | * @param {any} x Unknown variable that might contain a boolean. 41 | */ 42 | function tryCreateBool(x) { 43 | if (x === '1' || x === 1 || x === 'yes' || x === 'true') { 44 | return true; 45 | } 46 | if (x === '0' || x === 0 || x === 'no' || x === 'false') { 47 | return false; 48 | } 49 | return x; 50 | } 51 | 52 | /** 53 | * Trys to create a JS Date from a variable of unknown type. 54 | * 55 | * @param {any} x Unknown variable that might contain a date. 56 | */ 57 | function tryCreateDate(x) { 58 | if (x === null || x === undefined) return x; 59 | x = tryCreateInt(x); 60 | if (typeof x === 'number') { 61 | if (x < 99999999999) { // 99,999,999,999 or 100 billion minus 1 62 | // convert from seconds to milliseconds 63 | x *= 1000; 64 | } 65 | } 66 | try { 67 | const ret = new Date(x); 68 | if (isNaN(ret)) return x; 69 | return ret; 70 | } catch (e) { 71 | return x; 72 | } 73 | } 74 | 75 | /** 76 | * Construct an object with lat and lon keys from a variable of unknown type. 77 | * 78 | * @param {any} x 79 | */ 80 | function findLatLonFromVariable(x) { 81 | const latMin = -90; 82 | const latMax = 90; 83 | const lonMin = -180; 84 | const lonMax = 180; 85 | const type = typeof x; 86 | let lat, lon; 87 | if (type === 'object') { 88 | if (x.constructor && x.constructor === Array) { 89 | if (x.length === 2) { 90 | const a = x[0]; 91 | const b = x[1]; 92 | 93 | if (a > latMax || a < latMin) { 94 | lon = a; 95 | lat = b; 96 | } else { 97 | lat = a; 98 | lon = b; 99 | } 100 | return { lat, lon }; 101 | } else if (x.length === 1) { 102 | return findLatLonFromVariable(x[0]); 103 | } else { 104 | throw new Error('Unable to parse coordinates'); 105 | } 106 | } else if (x.constructor === Object) { 107 | if (x.lat) lat = x.lat; 108 | if (x.latitude) lat = x.latitude; 109 | if (x.y) lat = x.y; 110 | 111 | if (x.lon) lon = x.lon; 112 | if (x.longitude) lon = x.longitude; 113 | if (x.lng) lon = x.lng; 114 | if (x.long) lon = x.long; 115 | if (x.x) lon = x.x; 116 | 117 | if (lat === undefined) { 118 | throw new Error('Unable to find latitude from coordinates'); 119 | } 120 | if (lon === undefined) { 121 | throw new Error('Unable to find longitude from coordinates'); 122 | } 123 | return { lat, lon }; 124 | } 125 | } 126 | throw new Error('Unable to parse coordinates'); 127 | } 128 | 129 | class life360_helper { 130 | /** 131 | * Should be only used internally. Creates a helper object that will allow for communication with the Life360 API. 132 | * @param {life360} api 133 | * @param {object} [props] 134 | */ 135 | constructor(api) { 136 | if (api === undefined || !(api instanceof life360)) { 137 | throw new Error('First argument must be an instance of life360!'); 138 | } 139 | this.api = api; 140 | this.request = api.request.bind(this.api); 141 | } 142 | *[Symbol.iterator]() { 143 | if (this.length === undefined) { 144 | throw new Error('This object can not be iterated on!'); 145 | } 146 | for (let i = 0, len = this.length; i < len; i++) { 147 | yield this[i]; 148 | } 149 | } 150 | clearChildren() { 151 | for (let i = 0, len = this.length; i < len; i++) { 152 | this[i] = undefined; 153 | } 154 | this.length = 0; 155 | } 156 | addChild(child) { 157 | if (this.length === undefined) { 158 | this.length = 0; 159 | } 160 | this[this.length] = child; 161 | this.length++; 162 | return child; 163 | } 164 | } 165 | 166 | /** 167 | * Represents a Life360 Location Request. 168 | * 169 | * Use the check() method to ask the server for the status of the request. 170 | */ 171 | class life360_location_request extends life360_helper { 172 | populate(x) { 173 | Object.assign(this, x); 174 | 175 | this.requestId = x.requestId; 176 | this.isPollable = x.isPollable; 177 | } 178 | async check() { 179 | const json = await this.request('/v3/circles/members/request/' + this.requestId); 180 | if (json.status === 'A') { 181 | this.location = new life360_location(this.api); 182 | this.location.populate(json.location); 183 | this.success_response = json; 184 | return true; 185 | } 186 | return false; 187 | } 188 | } 189 | 190 | class life360_checkin_request extends life360_helper { 191 | populate(x) { 192 | Object.assign(this, x); 193 | 194 | this.requestId = x.requestId; 195 | this.isPollable = x.isPollable; 196 | } 197 | async check() { 198 | // TODO: Figure out why this is returning the following. 199 | /* 200 | { 201 | "errorMessage": "Unable to find request", 202 | "url": "/v3/circles/members/request/REQUEST_GUID_HIDDEN", 203 | "status": 400 204 | } 205 | */ 206 | this.json = await this.request('/v3/circles/members/request/' + this.requestId); 207 | if (json.status === 'A') { 208 | this.location = json.location; 209 | this.success_response = json; 210 | return true; 211 | } 212 | return false; 213 | } 214 | } 215 | 216 | class life360_circle extends life360_helper { 217 | populate(x) { 218 | /** 219 | * id, color, name, type 220 | */ 221 | Object.assign(this, x); 222 | 223 | this.createdAt = tryCreateDate(x.createdAt); 224 | this.memberCount = tryCreateInt(x.memberCount); 225 | this.unreadMessages = tryCreateInt(x.unreadMessages); 226 | this.unreadNotifications = tryCreateInt(x.unreadNotifications); 227 | 228 | if (x.features instanceof Object) { 229 | if (x.features.premium !== undefined) { 230 | x.features.premium = tryCreateInt(x.features.premium); 231 | x.features.priceMonth = tryCreateInt(x.features.priceMonth); 232 | x.features.priceYear = tryCreateInt(x.features.priceYear); 233 | } 234 | this.features = x.features; 235 | } 236 | 237 | this.members = new life360_member_list(this.api); 238 | this.members.circle = this; 239 | if (x.members) { 240 | this.members.populate(x.members); 241 | } 242 | } 243 | async refresh() { 244 | const json = await this.request('/v3/circles/' + this.id); 245 | this.populate(json); 246 | return this; 247 | } 248 | 249 | 250 | async allPlaces() { 251 | const json = await this.request('/v3/circles/' + this.id + '/allplaces'); 252 | // todo: return life360_place_list 253 | const places = new life360_place_list(this); 254 | places.populate(json.places); 255 | return places; 256 | } 257 | async code() { 258 | if (global.DEBUG_FLAG === false) throw 'not implemented'; 259 | const json = await this.request('/v3/circles/' + this.id + '/code'); 260 | return json; 261 | } 262 | async emergencycontacts() { 263 | if (global.DEBUG_FLAG === false) throw 'not implemented'; 264 | const json = await this.request('/v3/circles/' + this.id + '/emergencyContacts'); 265 | const emergencyContacts = json.emergencyContacts; // array 266 | for (const emergencyContact of emergencyContacts) { 267 | debugger; 268 | } 269 | debugger; 270 | return json; 271 | } 272 | async member(member_id) { 273 | const json = await this.request('/v3/circles/' + this.id + '/members/' + member_id); 274 | debugger; 275 | return json; 276 | } 277 | async memberAlerts() { 278 | const json = await this.request('/v3/circles/' + this.id + '/member/alerts'); 279 | debugger; 280 | return json; 281 | } 282 | async memberPreferences() { 283 | const json = await this.request('/v3/circles/' + this.id + '/member/preferences'); 284 | debugger; 285 | return json; 286 | } 287 | 288 | 289 | async membersHistory(since) { 290 | let params; 291 | if (since !== undefined) { 292 | if (since instanceof Date) { 293 | since = Math.floor(a.getTime() / 1000); 294 | } else if (typeof since === 'string') { 295 | since = Math.floor((new Date(since)).getTime() / 1000); 296 | } 297 | params = { since }; 298 | } 299 | const json = await this.request('/v3/circles/' + this.id + '/members/history', { params }); 300 | const locations = new life360_location_list(this.api); 301 | for (let i = 0; i < json.locations.length; i++) { 302 | const location = new life360_location(this.api); 303 | location.populate(json[i].locations); 304 | locations.addChild(location); 305 | } 306 | return locations; 307 | } 308 | async listMembers() { 309 | const json = await this.request('/v3/circles/' + this.id + '/members'); 310 | this.members = new life360_member_list(this.api); 311 | this.members.circle = this; 312 | this.members.populate(json.members); 313 | return this.members; 314 | } 315 | async listMessages(count) { 316 | let params; 317 | if (count !== undefined) params = { count }; 318 | const json = await this.request('/v3/circles/' + this.id + '/messages', { 319 | params, 320 | }); 321 | debugger; 322 | return json; 323 | } 324 | async listNearbyplaces(lat, lon, wifiscan) { 325 | let params; 326 | if (wifiscan !== undefined) { 327 | params = { wifiscan: wifiscan }; 328 | } 329 | const json = await this.request('/v3/circles/' + this.id + '/nearbyplaces/' + lat + '/' + lon); 330 | // todo: return life360_place_list 331 | debugger; 332 | return json; 333 | } 334 | async listPlaces() { 335 | const json = await this.request('/v3/circles/' + this.id + '/places'); 336 | // todo: return life360_place_list 337 | debugger; 338 | return json; 339 | } 340 | async watchlist() { 341 | if (global.DEBUG_FLAG === false) throw 'not implemented'; 342 | const json = await this.request('/v3/circles/' + this.id + '/driverbehavior/watchlist'); 343 | debugger; 344 | /* 345 | { 346 | "watchlist": [], 347 | "sdkEnabled": { 348 | "4e7f416b-ae08-40c9-be78-03fe0adcadae": "OFF", 349 | "bfc4a9d2-639e-49bd-b7a1-f6a3356a7b18": "OFF" 350 | } 351 | } 352 | */ 353 | return json; 354 | } 355 | 356 | async setCode(code) { 357 | const json = await this.request('/v3/circles/' + this.id + '/code', { 358 | method: 'post', 359 | }); 360 | debugger; 361 | return json; 362 | } 363 | async startSmartRealTime() { 364 | const json = await this.request('/v3/circles/' + this.id + '/smartRealTime/start', { 365 | method: 'post', 366 | }); 367 | debugger; 368 | return json; 369 | } 370 | async sendMessage() { 371 | const json = await this.request('/v3/circles/' + this.id + '/threads/message', { 372 | method: 'post', 373 | }); 374 | debugger; 375 | return json; 376 | } 377 | } 378 | class life360_circle_list extends life360_helper { 379 | populate(x) { 380 | for (let i = 0; i < x.circles.length; i++) { 381 | const circle = new life360_circle(this.api); 382 | circle.populate(x.circles[i]); 383 | this.addChild(circle); 384 | } 385 | } 386 | findById(id) { 387 | for (const circle of this) { 388 | if (circle.id === id) { 389 | return circle; 390 | } 391 | } 392 | } 393 | findByName(name) { 394 | const regex = new RegExp('.*' + name + '.*', 'i'); 395 | for (const circle of this) { 396 | if (circle.name.match(regex)) { 397 | return circle; 398 | } 399 | } 400 | } 401 | } 402 | 403 | class life360_crime extends life360_helper { 404 | populate(x) { 405 | Object.assign(this, x); 406 | 407 | this.incidentDate = tryCreateDate(x.incidentDate); 408 | this.incident_date = tryCreateDate(x.incident_date); 409 | 410 | this.latitude = tryCreateFloat(x.latitude); 411 | this.longitude = tryCreateFloat(x.longitude); 412 | 413 | this.id = tryCreateInt(x.id); 414 | } 415 | } 416 | class life360_crime_list extends life360_helper {} 417 | 418 | class life360_offender extends life360_helper { 419 | populate(x) { 420 | Object.assign(this, x); 421 | 422 | this.age = tryCreateInt(x.age); 423 | this.latitude = tryCreateFloat(x.latitude); 424 | this.longitude = tryCreateFloat(x.longitude); 425 | this.weight = tryCreateInt(x.weight); 426 | } 427 | } 428 | 429 | class life360_offender_list extends life360_helper {} 430 | 431 | class life360_safetypoint extends life360_helper { 432 | populate(x) { 433 | Object.assign(this, x); 434 | 435 | this.incidentDate = tryCreateDate(x.incidentDate); 436 | this.incident_date = tryCreateDate(x.incident_date); 437 | 438 | this.latitude = tryCreateFloat(x.latitude); 439 | this.longitude = tryCreateFloat(x.longitude); 440 | 441 | this.id = tryCreateInt(x.id); 442 | } 443 | } 444 | class life360_safetypoint_list extends life360_helper {} 445 | 446 | class life360_location extends life360_helper { 447 | populate(x) { 448 | Object.assign(this, x); 449 | /** 450 | * address1, address2, driveSDKStatus, lat, lon, name, placeType 451 | * shortAddress, source, sourceId, tripId, userActivity 452 | */ 453 | 454 | this.startTimestamp = tryCreateDate(x.startTimestamp); 455 | this.endTimestamp = tryCreateDate(x.endTimestamp); 456 | this.since = tryCreateDate(x.since); 457 | this.timestamp = tryCreateDate(x.timestamp); 458 | 459 | this.accuracy = tryCreateInt(x.accuracy); 460 | this.battery = tryCreateInt(x.battery); 461 | this.charge = tryCreateInt(x.charge); 462 | this.speed = tryCreateInt(x.speed); 463 | 464 | this.inTransit = tryCreateBool(x.inTransit); 465 | this.isDriving = tryCreateBool(x.isDriving); 466 | this.wifiState = tryCreateBool(x.wifiState); 467 | } 468 | } 469 | class life360_location_list extends life360_helper {} 470 | 471 | class life360_member extends life360_helper { 472 | populate(x) { 473 | /** 474 | * id, activity, avatar, avatarAuthor, cobranding, communications 475 | * firstName, issues, langauge, lastName, locale, loginEmail, loginPhone 476 | * medical, pinNumber, relation 477 | */ 478 | Object.assign(this, x); 479 | 480 | this.created = tryCreateDate(x.created); 481 | this.createdAt = tryCreateDate(x.createdAt); 482 | 483 | this.isAdmin = tryCreateBool(x.isAdmin); 484 | if (x.location) { 485 | this.location = new life360_location(this.api); 486 | this.location.populate(x.location); 487 | } 488 | 489 | if (x.settings) { 490 | /* 491 | settings = { 492 | alerts: { 493 | crime: bool, 494 | sound: bool, 495 | }, 496 | dateFormat: string, 497 | locale: string, 498 | map: { 499 | advisor: bool, 500 | crime: bool, 501 | crimeDuration: string, 502 | family: bool, 503 | fire: bool, 504 | hospital: bool, 505 | memberRadius: bool, 506 | placeRadius: bool, 507 | police: bool, 508 | sexOffenders: bool, 509 | }, 510 | timeZone: string, 511 | unitOfMeasure: string, 512 | zendrive: { 513 | sdk_enabled: string 514 | } 515 | } 516 | */ 517 | if (x.settings.alerts) { 518 | x.settings.alerts.crime = tryCreateBool(x.settings.alerts.crime); 519 | x.settings.alerts.sound = tryCreateBool(x.settings.alerts.sound); 520 | } 521 | if (x.settings.map) { 522 | x.settings.map.advisor = tryCreateBool(x.settings.map.advisor); 523 | x.settings.map.crime = tryCreateBool(x.settings.map.crime); 524 | x.settings.map.crimeDuration = tryCreateBool(x.settings.map.crimeDuration); 525 | x.settings.map.family = tryCreateBool(x.settings.map.family); 526 | x.settings.map.fire = tryCreateBool(x.settings.map.fire); 527 | x.settings.map.hospital = tryCreateBool(x.settings.map.hospital); 528 | x.settings.map.memberRadius = tryCreateBool(x.settings.map.memberRadius); 529 | x.settings.map.placeRadius = tryCreateBool(x.settings.map.placeRadius); 530 | x.settings.map.police = tryCreateBool(x.settings.map.police); 531 | x.settings.map.sexOffenders = tryCreateBool(x.settings.map.sexOffenders); 532 | } 533 | this.settings = x.settings; 534 | } 535 | 536 | if (x.issues) { 537 | x.issues.disconnected = tryCreateBool(x.issues.disconnected); 538 | x.issues.troubleshooting = tryCreateBool(x.issues.troubleshooting); 539 | this.issues = x.issues; 540 | } 541 | 542 | if (x.features !== undefined) { 543 | this.features = {}; 544 | const keys = [ 545 | 'device', 'disconnected', 'geofencing', 'mapDisplay', 546 | 'nonSmartphoneLocating', 'pendingInvite', 'shareLocation', 547 | 'smartphone' 548 | ]; 549 | keys.forEach(key => { 550 | if (x.features[key] !== undefined) this.features[key] = tryCreateBool(x.features[key]); 551 | }); 552 | if (x.features.shareOffTimestamp !== undefined) this.features.shareOffTimestamp = tryCreateDate(x.features.shareOffTimestamp); 553 | } 554 | } 555 | async refresh() { 556 | const json = await this.api.member(this.circle.id, this.id); 557 | this.populate(json); 558 | return this; 559 | } 560 | async history(time) { 561 | let params; 562 | if (time !== undefined) { 563 | if (time instanceof Date) { 564 | time = Math.floor(a.getTime() / 1000); 565 | } else if (typeof time === 'string') { 566 | time = Math.floor((new Date(time)).getTime() / 1000); 567 | } 568 | params = { time: time }; 569 | } 570 | const json = await this.request('/v3/circles/' + this.circle.id + '/members/' + this.id + '/history', { params }); 571 | const locations = new life360_location_list(this.api); 572 | for (let i = 0; i < json.locations.length; i++) { 573 | const location = new life360_location(this.api); 574 | location.populate(json.locations[i]); 575 | locations.addChild(location); 576 | } 577 | return locations; 578 | } 579 | async requestLocation() { 580 | const json = await this.request('/v3/circles/' + this.circle.id + '/members/' + this.id + '/request', { 581 | method: 'post', 582 | body: { 583 | type: 'location', 584 | }, 585 | }); 586 | const request = new life360_location_request(this.api); 587 | request.populate(json); 588 | request.member = this; 589 | request.circle = this.circle; 590 | return request; 591 | } 592 | async requestCheckIn() { 593 | const json = await this.request('/v3/circles/' + this.circle.id + '/members/' + this.id + '/request', { 594 | method: 'post', 595 | body: { 596 | type: 'checkin', 597 | }, 598 | }); 599 | const request = new life360_checkin_request(this.api); 600 | request.populate(json); 601 | request.member = this; 602 | request.circle = this.circle; 603 | return request; 604 | } 605 | } 606 | class life360_member_list extends life360_helper { 607 | populate(x) { 608 | for (let i = 0; i < x.length; i++) { 609 | const member = new life360_member(this.api); 610 | member.populate(x[i]); 611 | member.circle = this; 612 | this.addChild(member); 613 | } 614 | } 615 | findById(id) { 616 | for (const member of this) { 617 | if (member.id === id) { 618 | return member; 619 | } 620 | } 621 | } 622 | findByName(name) { 623 | const regex = new RegExp('.*' + name + '.*', 'i'); 624 | for (const member of this) { 625 | if (member.firstName.match(regex)) { 626 | return member; 627 | } 628 | if (member.lastName.match(regex)) { 629 | return member; 630 | } 631 | const fullName = member.firstName + ' ' + member.lastName; 632 | if (fullName.match(regex)) { 633 | return member; 634 | } 635 | } 636 | } 637 | } 638 | 639 | class life360_message extends life360_helper {} 640 | 641 | class life360_place extends life360_helper {} 642 | 643 | class life360_thread extends life360_helper {} 644 | 645 | class life360_session extends life360_helper { 646 | populate(x) { 647 | Object.assign(this, x); 648 | 649 | this.token_type = x.token_type; 650 | this.access_token = x.access_token; 651 | } 652 | } 653 | 654 | class life360 { 655 | /** 656 | * @returns life360 657 | */ 658 | static login() { 659 | if (this._instance === undefined) this._instance = new life360(); 660 | return this._instance.login.apply(this._instance, arguments); 661 | } 662 | static logout() { 663 | if (this._instance === undefined) this._instance = new life360(); 664 | return this._instance.logout.apply(this._instance, arguments); 665 | } 666 | static crimes() { 667 | if (this._instance === undefined) this._instance = new life360(); 668 | return this._instance.listCrimes.apply(this._instance, arguments); 669 | } 670 | static me() { 671 | if (this._instance === undefined) this._instance = new life360(); 672 | return this._instance.me.apply(this._instance, arguments); 673 | } 674 | static circles() { 675 | if (this._instance === undefined) this._instance = new life360(); 676 | return this._instance.listCircles.apply(this._instance, arguments); 677 | } 678 | static safetyPoints() { 679 | if (this._instance === undefined) this._instance = new life360(); 680 | return this._instance.listSafetyPoints.apply(this._instance, arguments); 681 | } 682 | static offenders() { 683 | if (this._instance === undefined) this._instance = new life360(); 684 | return this._instance.offenders.apply(this._instance, arguments); 685 | } 686 | constructor() { 687 | this.BASIC_AUTH = 'Basic U3dlcUFOQWdFVkVoVWt1cGVjcmVrYXN0ZXFhVGVXckFTV2E1dXN3MzpXMnZBV3JlY2hhUHJlZGFoVVJhZ1VYYWZyQW5hbWVqdQ=='; 688 | this.defaults = { 689 | hostname: 'www.life360.com', 690 | headers: { 691 | Accept: 'application/json', 692 | 'X-Application': 'life360-web-client', 693 | 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36', 694 | }, 695 | }; 696 | } 697 | enableDebugging() { 698 | global.DEBUG_FLAG = true; 699 | } 700 | disableDebugging() { 701 | global.DEBUG_FLAG = false; 702 | } 703 | _getDeviceId() { 704 | if (this._deviceId === undefined) { 705 | const bytes = Buffer.alloc(8); 706 | for (let i = 0; i < 8; i++) { 707 | bytes[i] = Math.floor(Math.random() * 256); 708 | } 709 | this._deviceId = bytes.toString('hex'); 710 | } 711 | return this._deviceId; 712 | } 713 | /** 714 | * Asks Life360.com to login a user. 715 | */ 716 | async login() { 717 | const body = { 718 | countryCode: 1, 719 | password: '', 720 | username: '', 721 | phone: '', 722 | grant_type: 'password', 723 | }; 724 | if (arguments.length === 0) { 725 | throw new Error('Must provide an argument to life360.login'); 726 | } else if (arguments.length === 1) { 727 | let arg = arguments[0]; 728 | if (typeof arg === 'object') { 729 | if (arg.username !== undefined) body.username = arg.username; 730 | if (arg.user !== undefined) body.username = arg.user; 731 | if (arg.email !== undefined) body.username = arg.email; 732 | 733 | if (arg.phone !== undefined) body.phone = arg.phone; 734 | 735 | if (arg.password !== undefined) body.password = arg.password; 736 | if (arg.pass !== undefined) body.password = arg.pass; 737 | } else { 738 | throw new Error('First and only argument must be an object'); 739 | } 740 | } else if (arguments.length === 2) { 741 | let arg1 = arguments[0]; 742 | let arg2 = arguments[1]; 743 | if (typeof arg1 === 'string') { 744 | let emailRegex = /^[^@]+@[^\.]+$/; 745 | if (arg1.match(emailRegex)) { 746 | body.username = arg1; 747 | } else { 748 | let phoneRegex = /^[0-9()-+ #\.]+$/; 749 | if (arg1.match(phoneRegex)) { 750 | body.phone = arg1; 751 | } else { 752 | body.username = arg1; 753 | } 754 | } 755 | } 756 | if (typeof arg2 === 'string') { 757 | body.password = arg2; 758 | } else { 759 | throw new Error('Second argument must be a password string'); 760 | } 761 | } 762 | const json = await this.request('/v3/oauth2/token', { 763 | authorization: this.BASIC_AUTH, 764 | body: body, 765 | headers: { 766 | 'X-Device-Id': this._getDeviceId(), 767 | } 768 | }); 769 | let token_type = json.token_type; 770 | if (!token_type) token_type = 'Bearer'; 771 | this.session = new life360_session(this); 772 | this.session.populate(json); 773 | return this; 774 | } 775 | async logout() { 776 | if (this.session) { 777 | let access_token = this.session.access_token; 778 | let token_type = this.session.token_type; 779 | if (global.DEBUG_FLAG === false) throw 'not implemented'; 780 | debugger; 781 | this.session = undefined; 782 | } else { 783 | throw new Error('Not logged in.'); 784 | } 785 | } 786 | async putLocation(data) { 787 | const geolocation = {}; 788 | const geolocationMetadata = {}; 789 | const device = {}; 790 | 791 | if (data.lat) geolocation.lat = data.lat; 792 | if (data.lon) geolocation.lon = data.lon; 793 | if (data.alt) geolocation.alt = data.alt; 794 | if (data.accuracy) geolocation.accuracy = data.accuracy; 795 | if (data.heading) geolocation.heading = data.heading; 796 | if (data.speed) geolocation.speed = data.speed; 797 | if (data.timestamp) geolocation.timestamp = data.timestamp; 798 | if (data.age) geolocation.age = data.age; 799 | 800 | if (data.wssid) geolocationMetadata.wssid = data.wssid; 801 | if (data.reqssid) geolocationMetadata.reqssid = data.reqssid; 802 | if (data.lmode) geolocationMetadata.lmode = data.lmode; 803 | 804 | if (data.battery) device.battery = data.battery; 805 | if (data.charge) device.charge = data.charge; 806 | if (data.wifiState) device.wifi_state = data.wifiState; 807 | if (data.build) device.build = data.build; 808 | 809 | /* Make the inputs conform to the life360's api. */ 810 | 811 | if (geolocation.alt === undefined) geolocation.alt = '0.0'; 812 | if (geolocation.accuracy === undefined) geolocation.accuracy = '10.00'; 813 | if (geolocation.heading === undefined) geolocation.heading = '0.0'; 814 | if (geolocation.speed === undefined) geolocation.speed = '0.0'; 815 | if (geolocation.timestamp === undefined) { 816 | const nowInSeconds = Math.floor(new Date().getTime() / 1000); 817 | geolocation.timestamp = nowInSeconds + ''; 818 | } 819 | 820 | if (device.build === undefined) device.build = '228980'; 821 | if (device.driveSDKStatus === undefined) device.driveSDKStatus = 'OFF'; 822 | if (device.userActivity === undefined) device.userActivity = 'unknown'; 823 | 824 | /* Change types of all properties to strings */ 825 | 826 | if (typeof geolocation.lat === 'number') geolocation.lat = geolocation.lat + '' 827 | if (typeof geolocation.lon === 'number') geolocation.lon = geolocation.lon + '' 828 | 829 | const userContext = { 830 | geolocation: geolocation, 831 | geolocation_meta: geolocationMetadata, 832 | device: device 833 | }; 834 | const userContextBase64 = Buffer.from(JSON.stringify(userContext)).toString('base64'); 835 | const json = await this.request('/v4/locations', { 836 | hostname: 'android.life360.com', 837 | method: 'put', 838 | headers: { 839 | 'X-Device-ID': this._getDeviceId(), 840 | 'X-UserContext': userContextBase64, 841 | } 842 | }); 843 | return json; 844 | } 845 | async listCrimes(args) { 846 | const params = {}; 847 | if (args) { 848 | if (args.start) params.startDate = args.start; 849 | if (args.end) params.endDate = args.end; 850 | if (args.page) params.page = args.page; 851 | if (args.pageSize) params.pageSize = args.pageSize; 852 | if (args.topLeft) { 853 | const topLeftLatLon = findLatLonFromVariable(args.topLeft); 854 | params['boundingBox[topLeftLatitude]'] = topLeftLatLon.lat; 855 | params['boundingBox[topLeftLongitude]'] = topLeftLatLon.lon; 856 | } 857 | if (args.bottomRight) { 858 | const bottomRightLatLon = findLatLonFromVariable(args.bottomRight); 859 | params['boundingBox[bottomRightLatitude]'] = bottomRightLatLon.lat; 860 | params['boundingBox[bottomRightLongitude]'] = bottomRightLatLon.lon; 861 | } 862 | if (args.topLeftLat) params['boundingBox[topLeftLatitude]'] = args.topLeftLat; 863 | if (args.topLeftLon) params['boundingBox[topLeftLongitude]'] = args.topLeftLon; 864 | if (args.bottomRightLat) params['boundingBox[bottomRightLatitude]'] = args.bottomRightLat; 865 | if (args.bottomRightLon) params['boundingBox[bottomRightLongitude]'] = args.bottomRightLon; 866 | } 867 | if (params.startDate instanceof Date) { 868 | params.startDate = Math.floor(params.startDate.getTime() / 1000); 869 | } 870 | if (params.endDate instanceof Date) { 871 | params.endDate = Math.floor(params.endDate.getTime() / 1000); 872 | } 873 | const json = await this.request('/v3/crimes', { params }); 874 | const crimes = new life360_crime_list(this); 875 | for (let i = 0; i < json.crimes.length; i++) { 876 | const crime = new life360_crime(this); 877 | crime.populate(json.crimes[i]); 878 | crimes.addChild(crime); 879 | } 880 | return crimes; 881 | } 882 | async me() { 883 | const json = await this.request('/v3/users/me'); 884 | this._me = new life360_member(this); 885 | this._me.populate(json); 886 | return this._me; 887 | } 888 | async listCircles() { 889 | const json = await this.request('/v3/circles'); 890 | this._circles = new life360_circle_list(this); 891 | this._circles.populate(json); 892 | return this._circles; 893 | } 894 | async listSafetyPoints() { 895 | const params = {}; 896 | const args = arguments; 897 | if (args.length === 1) { 898 | const latLon = findLatLonFromVariable(args[0]); 899 | params['centerPoint[latitude]'] = latLon.lat; 900 | params['centerPoint[longitude]'] = latLon.lon; 901 | } else if (args.length === 2) { 902 | params['centerPoint[latitude]'] = args[0]; 903 | params['centerPoint[longitude]'] = args[1]; 904 | } 905 | const json = await this.request('/v3/safetyPoints', { params }); 906 | const locations = new life360_safetypoint_list(this); 907 | for (let i = 0; i < json.safetyPoints.length; i++) { 908 | const location = new life360_safetypoint(this); 909 | location.populate(json.safetyPoints[i]); 910 | location.locationType = 'safetyPoint'; 911 | locations.addChild(location); 912 | } 913 | return locations; 914 | } 915 | async listOffenders(args) { 916 | const params = {}; 917 | if (args) { 918 | if (args.limit) params['limit'] = args.limit; 919 | if (args.topLeft) { 920 | const topLeftLatLon = findLatLonFromVariable(args.topLeft); 921 | params['boundingBox[topLeftLatitude]'] = topLeftLatLon.lat; 922 | params['boundingBox[topLeftLongitude]'] = topLeftLatLon.lon; 923 | } 924 | if (args.bottomRight) { 925 | const bottomRightLatLon = findLatLonFromVariable(args.bottomRight); 926 | params['boundingBox[bottomRightLatitude]'] = bottomRightLatLon.lat; 927 | params['boundingBox[bottomRightLongitude]'] = bottomRightLatLon.lon; 928 | } 929 | if (args.topLeftLat) params['boundingBox[topLeftLatitude]'] = args.topLeftLat; 930 | if (args.topLeftLon) params['boundingBox[topLeftLongitude]'] = args.topLeftLon; 931 | if (args.bottomRightLat) params['boundingBox[bottomRightLatitude]'] = args.bottomRightLat; 932 | if (args.bottomRightLon) params['boundingBox[bottomRightLongitude]'] = args.bottomRightLon; 933 | } 934 | const json = await this.request('/v3/offenders', { params }); 935 | const offenders = new life360_offender_list(this); 936 | for (let i = 0; i < json.offenders.length; i++) { 937 | const offender = new life360_offender(this); 938 | offender.populate(json.offenders[i]); 939 | offenders.addChild(offender); 940 | } 941 | return offenders; 942 | } 943 | request(a, b) { 944 | let options; 945 | if (b === undefined) { 946 | if (typeof a === 'string') { 947 | options = { path: a }; 948 | } 949 | } else { 950 | options = b; 951 | options.path = a; 952 | } 953 | const self = this; 954 | 955 | function findHeaderCaseInsensitive(name, headers) { 956 | const keys = Object.keys(headers); 957 | for (let i = 0; i < keys.length; i++) { 958 | const key = keys[i]; 959 | if (key.localeCompare(name, self.locale, { sensitivity: 'base' }) === 0) { 960 | return headers[key]; 961 | } 962 | } 963 | } 964 | 965 | let hostname = this.defaults.hostname; 966 | let path = this.defaults.path; 967 | let encoding = this.defaults.encoding; 968 | let headers = {}; 969 | let body = this.defaults.body; 970 | let type = this.defaults.type; 971 | let method = this.defaults.method; 972 | let params = this.defaults.params; 973 | if (options.hostname) { 974 | hostname = options.hostname; 975 | } 976 | if (options.path) { 977 | path = options.path; 978 | } else { 979 | path = '/'; 980 | } 981 | if (this.defaults.headers) { 982 | const keys = Object.keys(this.defaults.headers); 983 | for (let i = 0; i < keys.length; i++) { 984 | const key = keys[i]; 985 | const value = this.defaults.headers[key]; 986 | headers[key] = value; 987 | } 988 | } 989 | if (options.params) { 990 | params = options.params; 991 | if (typeof params === 'object') { 992 | params = querystring.stringify(params); 993 | } 994 | } 995 | if (path.length < 1 || path[0] !== '/') { 996 | path = '/' + path; 997 | } 998 | if (options.encoding) { 999 | encoding = encoding; 1000 | } 1001 | if (typeof headers !== 'object' && headers !== undefined) { 1002 | throw new Error('Default headers is not an object!'); 1003 | } 1004 | if (encoding === undefined) { 1005 | encoding = 'utf-8'; 1006 | } 1007 | if (options.method) { 1008 | method = options.method.toLocaleUpperCase(); 1009 | } else { 1010 | if (options.body) { 1011 | method = 'POST'; 1012 | } else { 1013 | method = 'GET'; 1014 | } 1015 | } 1016 | if (options.headers) { 1017 | const headersKeys = Object.keys(options.headers); 1018 | for (let i = 0; i < headersKeys.length; i++) { 1019 | headers[headersKeys[i]] = options.headers[headersKeys[i]]; 1020 | } 1021 | } 1022 | if (options.body) { 1023 | if (options.type) { 1024 | type = options.type; 1025 | } else { 1026 | type = 'form-urlencoded'; 1027 | } 1028 | body = options.body; 1029 | if (type === 'json') { 1030 | body = JSON.stringify(body); 1031 | headers['Content-Type'] = 'application/json'; 1032 | } else if (type === 'form-urlencoded') { 1033 | if (typeof body === 'object') { 1034 | body = querystring.stringify(body); 1035 | } else if (typeof body === 'string') { 1036 | // no-op 1037 | } else { 1038 | throw new Error('A url encoded body must be an object!'); 1039 | } 1040 | headers['Content-Type'] = 'application/x-www-form-urlencoded'; 1041 | } else { 1042 | if (typeof body !== 'string' && !(body instanceof Buffer)) { 1043 | throw new Error('Body must be turned into a string!'); 1044 | } 1045 | if (type.indexOf('/') === -1) { 1046 | headers['Content-Type'] = 'application/' + type; 1047 | } else { 1048 | headers['Content-Type'] = type; 1049 | } 1050 | } 1051 | if (!(body instanceof Buffer)) { 1052 | body = Buffer.from(body); 1053 | } 1054 | headers['Content-Length'] = body.byteLength; 1055 | } 1056 | 1057 | let authorization; 1058 | if (options.auth) { 1059 | authorization = options.auth; 1060 | } else if (options.authorization) { 1061 | authorization = options.authorization; 1062 | } else if (this.session) { 1063 | authorization = this.session.token_type + ' ' + this.session.access_token 1064 | } 1065 | if (authorization) { 1066 | if (typeof authorization === 'string') { 1067 | if (authorization.indexOf(' ') === -1) { 1068 | authorization = 'Basic ' + authorization; 1069 | } 1070 | headers.Authorization = authorization; 1071 | } else if (typeof authorization === 'object') { 1072 | let auth_type; 1073 | let base64_value; 1074 | if (authorization.base64) { 1075 | base64_value = authorization.base64; 1076 | } 1077 | if (authorization.value) { 1078 | base64_value = Buffer.from(authorization.value).toString('base64'); 1079 | } 1080 | if (authorization.type) { 1081 | auth_type = authorization.type; 1082 | if (auth_type === 'basic') auth_type = 'Basic'; 1083 | if (auth_type === 'bearer') auth_type = 'Bearer'; 1084 | if (auth_type === 'digest') auth_type = 'Digest'; 1085 | } 1086 | if (auth_type === undefined) { 1087 | auth_type = 'Basic'; 1088 | } 1089 | headers.Authorization = auth_type + ' ' + base64_value; 1090 | } else { 1091 | throw new Error('Invalid authorization type!'); 1092 | } 1093 | } 1094 | 1095 | if (params) { 1096 | path += '?' + params; 1097 | } 1098 | const request_options = { 1099 | hostname: hostname, 1100 | path: path, 1101 | method: method, 1102 | headers: headers, 1103 | }; 1104 | return new Promise((ok, fail) => { 1105 | let request = https.request(request_options, (res) => { 1106 | //res.setEncoding(encoding); 1107 | 1108 | let buffer; 1109 | let bodyCharset = encoding; 1110 | let bodyType; 1111 | 1112 | // Find the server-provided content-type and character set 1113 | const contentTypeHeader = findHeaderCaseInsensitive('content-type', res.headers); 1114 | if (contentTypeHeader !== undefined) { 1115 | const parts = contentTypeHeader.split(';'); 1116 | bodyType = parts[0]; 1117 | for (let i = 1; i < parts.length; i++) { 1118 | const part = parts[i].split('='); 1119 | if (part[0] === 'charset') { 1120 | if (part.length > 1) { 1121 | bodyCharset = part[1]; 1122 | } 1123 | } 1124 | } 1125 | } 1126 | 1127 | res.on('data', function(chunk) { 1128 | if (buffer === undefined) { 1129 | buffer = chunk; 1130 | } else { 1131 | buffer = Buffer.concat([buffer, chunk]); 1132 | } 1133 | }); 1134 | res.on('end', function() { 1135 | try { 1136 | // done reading body 1137 | 1138 | let body; 1139 | if (buffer) { 1140 | if (bodyType === 'application/json') { 1141 | body = JSON.parse(buffer); 1142 | } else { 1143 | body = Buffer.from(buffer, bodyCharset); 1144 | } 1145 | } 1146 | 1147 | if (global.DEBUG_FLAG === false && typeof body === 'object' && body.errorMessage !== undefined) { 1148 | return fail(new Error('API responded with a ' + body.errorMessage)); 1149 | } else if (res.statusCode !== 200) { 1150 | return fail(new Error('Server responded with a ' + res.statusCode + ', ' + res.statusMessage)); 1151 | } 1152 | 1153 | return ok(body); 1154 | } catch (e) { 1155 | fail(e); 1156 | } 1157 | }); 1158 | }); 1159 | request.on('error', (e) => { fail(e); }); 1160 | if (body) { 1161 | request.write(body); 1162 | } 1163 | request.end(); 1164 | }); 1165 | } 1166 | } 1167 | 1168 | module.exports = life360; 1169 | --------------------------------------------------------------------------------